diff --git a/Cargo.lock b/Cargo.lock index 476af0dc89..f3e85a08a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6823,6 +6823,7 @@ dependencies = [ "skrifa 0.47.0", "svgtypes 0.16.1", "thiserror 2.0.21", + "unicode-bidi", "usvg 0.48.1", "vectorcraft-brush", "vectorcraft-color", diff --git a/ROADMAP.md b/ROADMAP.md index 346c532277..dabed3eb94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -124,6 +124,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged. + - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. - 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. 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/app.rs b/apps/vectorcraft/src/app.rs index 126b671877..dddaf0a91e 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -108,37 +108,14 @@ impl eframe::App for App { self.app.start_pending_update(); #[cfg(target_os = "macos")] super::mac_cursor::release(); + // Dropping the app can wait for the clipboard handles to let go: the windows leave the + // screen first (`quit_windows`). #[cfg(not(any(target_os = "macos", windows)))] - hide_before_teardown(self.winit_window.as_deref()); + super::quit_windows::before_teardown(self.winit_window.as_deref()); end_before_teardown(self.frames); } } -/// The main window as [`hide_before_teardown`] sees it. -#[cfg(not(any(target_os = "macos", windows)))] -pub(crate) trait QuittingWindow { - fn hide(&self); -} - -#[cfg(not(any(target_os = "macos", windows)))] -impl QuittingWindow for winit::window::Window { - fn hide(&self) { - self.set_visible(false); - } -} - -/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits -/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and -/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland -/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and -/// cannot hide a Wayland window. -#[cfg(not(any(target_os = "macos", windows)))] -pub(crate) fn hide_before_teardown(window: Option<&W>) { - if let Some(window) = window { - window.hide(); - } -} - impl App { /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a /// focused, shown window whose windows had input waits for the renderer and the next frame, diff --git a/apps/vectorcraft/src/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/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 3397ada7a5..c55785cfdf 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -60,6 +60,8 @@ mod portal_request; mod portal_save; mod prefs_dir; mod printing; +#[cfg(not(any(target_os = "macos", windows)))] +mod quit_windows; #[cfg(not(target_os = "macos"))] mod single_instance; #[cfg(windows)] @@ -90,8 +92,6 @@ mod titlebar; use app::bring_window_forward; use app::native_input_hook; use app::open_or_hold; -#[cfg(all(test, not(any(target_os = "macos", windows))))] -use app::{QuittingWindow, hide_before_teardown}; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -122,7 +122,7 @@ struct App { /// Keeps the custom title bar's window borderless (Windows, #1758). window_frame: Option, /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635), - /// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`). + /// and to take the window off screen on quit before the clipboard handles let go (`quit_windows`). winit_window: Option>, /// A saved or fitted geometry asked for maximize once the window is mapped (Linux). pending_maximize: bool, @@ -418,6 +418,10 @@ fn main() -> std::process::ExitCode { })); options }; + // Quitting takes the X11 windows of floating panels and documents off screen with the main + // window, so eframe's windows are recorded as it makes and drops them (`quit_windows`). + #[cfg(target_os = "linux")] + let options = eframe::NativeOptions { viewport_hooks: Some(std::sync::Arc::new(quit_windows::Hooks)), ..options }; #[cfg(feature = "wgpu")] let options = { let mut options = options; diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 6d1a8734b2..65970c87f7 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -242,7 +242,7 @@ fn parent(window: &winit::window::Window) -> (String, Option<()>) { /// (), made as rfd's `window_identifier` /// makes it. #[cfg(target_os = "linux")] -mod wayland { +pub(crate) mod wayland { use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::protocol::wl_registry::{self, WlRegistry}; use wayland_client::protocol::wl_surface::WlSurface; @@ -308,9 +308,10 @@ mod wayland { } } - /// `window`'s Wayland connection and surface, from its raw handles. + /// `window`'s Wayland connection and surface, from its raw handles. The caller holds `window` + /// while it uses them (`quit_windows` unmaps the main window's surface through them). #[allow(unsafe_code)] - fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { + pub(crate) fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { use wayland_backend::sys::client::{Backend, ObjectId}; use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle}; let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) = @@ -319,8 +320,8 @@ mod wayland { return None; }; // SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller - // holds it until the export is dropped); a backend made from a foreign display does not - // close it when dropped. + // holds it while it uses the connection, and the export holds it until it is dropped); a + // backend made from a foreign display does not close it when dropped. let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) }; let conn = Connection::from_backend(backend); // SAFETY: the pointer is the window's live `wl_surface` proxy on that display. diff --git a/apps/vectorcraft/src/quit_windows.rs b/apps/vectorcraft/src/quit_windows.rs new file mode 100644 index 0000000000..36497f4f4f --- /dev/null +++ b/apps/vectorcraft/src/quit_windows.rs @@ -0,0 +1,280 @@ +//! Takes the app's windows off screen when it quits on Linux and the BSDs, before eframe drops the +//! app. Dropping the app waits up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the +//! clipboard handles to let go, and eframe closes the windows only after that wait. +//! +//! The main window is hidden on X11 (XWayland included). winit cannot hide a Wayland window, so on +//! Linux a Wayland main window is unmapped instead: its surface gets no buffer, and the window stays +//! open with the connection the Wayland clipboard copies and reads on. On Linux the X11 windows of +//! floating panels and documents are recorded as eframe creates and drops them ([`Hooks`]), and +//! quitting unmaps them through an X11 connection of its own. On native Wayland, floating panels and +//! documents are normally drawn inside the main window. + +#[cfg(target_os = "linux")] +use std::collections::HashMap; +#[cfg(target_os = "linux")] +use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError}; + +#[cfg(target_os = "linux")] +use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + +/// The main window as quitting sees it. +pub(crate) trait QuittingWindow { + /// The window is a Wayland surface. + fn is_wayland(&self) -> bool; + /// Hides an X11 window. + fn hide(&self); + /// Takes a Wayland window off screen and keeps it open. + fn unmap(&self); +} + +impl QuittingWindow for winit::window::Window { + #[cfg(target_os = "linux")] + fn is_wayland(&self) -> bool { + matches!(self.window_handle().map(|h| h.as_raw()), Ok(RawWindowHandle::Wayland(_))) + } + + /// The BSDs have no Wayland unmap: their windows are hidden through winit. + #[cfg(not(target_os = "linux"))] + fn is_wayland(&self) -> bool { + false + } + + fn hide(&self) { + self.set_visible(false); + } + + #[cfg(target_os = "linux")] + fn unmap(&self) { + unmap_wayland(self); + } + + #[cfg(not(target_os = "linux"))] + fn unmap(&self) { + self.set_visible(false); + } +} + +/// Takes the main window and the floating windows `x11` lists off screen: a Wayland main window is +/// unmapped and any other main window hidden, and `unmap_x11` unmaps the floating windows. +pub(crate) fn off_screen(main: Option<&W>, x11: &[u32], unmap_x11: impl FnOnce(&[u32])) { + if let Some(window) = main { + if window.is_wayland() { + window.unmap(); + } else { + window.hide(); + } + } + if !x11.is_empty() { + unmap_x11(x11); + } +} + +/// [`off_screen`] with the running app's main window and the floating windows [`Hooks`] recorded. +/// Runs on quit, before eframe drops the app. +pub(crate) fn before_teardown(main: Option<&winit::window::Window>) { + #[cfg(target_os = "linux")] + off_screen(main, &CHILD_WINDOWS.x11(), unmap_x11); + #[cfg(not(target_os = "linux"))] + off_screen(main, &[], |_| {}); +} + +/// The X11 windows of floating panels and documents, by viewport, while eframe holds them. +#[cfg(target_os = "linux")] +#[derive(Default)] +pub(crate) struct ChildWindows(Mutex>); + +#[cfg(target_os = "linux")] +impl ChildWindows { + /// eframe made `viewport`'s window, with `handle`. The main window, and windows of other window + /// systems, are not recorded; a window made again for the same viewport replaces its record. + pub(crate) fn created(&self, viewport: egui::ViewportId, handle: Option) { + let mut windows = self.lock(); + match handle.and_then(x11_window) { + Some(id) if viewport != egui::ViewportId::ROOT => windows.insert(viewport, id), + _ => windows.remove(&viewport), + }; + } + + /// eframe dropped `viewport`'s window. + pub(crate) fn destroyed(&self, viewport: egui::ViewportId) { + self.lock().remove(&viewport); + } + + /// The recorded windows, in id order. + pub(crate) fn x11(&self) -> Vec { + let mut ids: Vec = self.lock().values().copied().collect(); + ids.sort_unstable(); + ids.dedup(); + ids + } + + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// The X11 window id of `handle`, None for another window system. +#[cfg(target_os = "linux")] +fn x11_window(handle: RawWindowHandle) -> Option { + match handle { + RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok(), + RawWindowHandle::Xcb(h) => Some(h.window.get()), + _ => None, + } +} + +/// The running app's floating windows, which [`Hooks`] records. +#[cfg(target_os = "linux")] +static CHILD_WINDOWS: LazyLock = LazyLock::new(ChildWindows::default); + +/// Records the windows eframe creates and drops in [`CHILD_WINDOWS`]; set as +/// [`eframe::NativeOptions::viewport_hooks`]. +#[cfg(target_os = "linux")] +pub(crate) struct Hooks; + +#[cfg(target_os = "linux")] +impl eframe::ViewportHooks for Hooks { + fn window_created(&self, viewport: egui::ViewportId, window: &winit::window::Window) { + CHILD_WINDOWS.created(viewport, window.window_handle().ok().map(|h| h.as_raw())); + } + + fn window_destroyed(&self, viewport: egui::ViewportId) { + CHILD_WINDOWS.destroyed(viewport); + } +} + +/// Unmaps the X11 `windows` through a connection of its own, and waits until the server has done so. +/// An id whose window is gone gets an error, which is ignored. +#[cfg(target_os = "linux")] +fn unmap_x11(windows: &[u32]) { + use x11rb::protocol::xproto::ConnectionExt as _; + let conn = match x11rb::connect(None) { + Ok((conn, _)) => conn, + Err(e) => { + log::warn!("couldn't reach the X server to hide the floating windows on quit: {e}"); + return; + } + }; + for &window in windows { + if let Ok(cookie) = conn.unmap_window(window) { + cookie.ignore_error(); + } + } + // The server answers once it has handled the requests before this one. + if let Ok(cookie) = conn.get_input_focus() { + let _ = cookie.reply(); + } +} + +/// Attaches no buffer to `window`'s Wayland surface and commits it, which unmaps the window: the +/// compositor stops showing it, and the window and its connection stay open. +#[cfg(target_os = "linux")] +fn unmap_wayland(window: &winit::window::Window) { + let Some((conn, surface)) = super::portal_save::wayland::surface(window) else { return }; + surface.attach(None, 0, 0); + surface.commit(); + if let Err(e) = conn.flush() { + log::warn!("couldn't reach the compositor to hide the window on quit: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + /// A main window that records what quitting did to it. + struct Window<'a> { + wayland: bool, + done: &'a RefCell>, + } + + impl QuittingWindow for Window<'_> { + fn is_wayland(&self) -> bool { + self.wayland + } + fn hide(&self) { + self.done.borrow_mut().push("hide".into()); + } + fn unmap(&self) { + self.done.borrow_mut().push("unmap".into()); + } + } + + fn quit(wayland: Option, x11: &[u32]) -> Vec { + let done = RefCell::new(Vec::new()); + let window = wayland.map(|wayland| Window { wayland, done: &done }); + off_screen(window.as_ref(), x11, |ids| done.borrow_mut().push(format!("unmap x11 {ids:?}"))); + done.into_inner() + } + + /// winit cannot hide a Wayland window: quitting unmaps it, and never only asks winit to hide it. + #[test] + fn quitting_unmaps_a_wayland_main_window() { + assert_eq!(quit(Some(true), &[]), ["unmap"]); + } + + /// An X11 main window (XWayland included) is hidden, and the floating panels' and documents' X11 + /// windows are unmapped with it. + #[test] + fn quitting_hides_an_x11_main_window_and_its_floating_windows() { + assert_eq!(quit(Some(false), &[]), ["hide"]); + assert_eq!(quit(Some(false), &[7, 9]), ["hide", "unmap x11 [7, 9]"]); + } + + /// Without a main window (none was made), the floating windows still go, and with none at all + /// nothing is done. + #[test] + fn quitting_without_a_main_window() { + assert_eq!(quit(None, &[3]), ["unmap x11 [3]"]); + assert!(quit(None, &[]).is_empty()); + } + + #[cfg(target_os = "linux")] + fn xlib(id: std::os::raw::c_ulong) -> Option { + Some(RawWindowHandle::Xlib(winit::raw_window_handle::XlibWindowHandle::new(id))) + } + + #[cfg(target_os = "linux")] + fn viewport(name: &str) -> egui::ViewportId { + egui::ViewportId::from_hash_of(name) + } + + /// The floating windows eframe made are recorded until it drops them; the main window, which + /// quitting reaches through winit, is not. + #[cfg(target_os = "linux")] + #[test] + fn floating_x11_windows_are_recorded_while_they_exist() { + let windows = ChildWindows::default(); + windows.created(egui::ViewportId::ROOT, xlib(1)); + windows.created(viewport("Layers"), xlib(9)); + let xcb = winit::raw_window_handle::XcbWindowHandle::new(std::num::NonZeroU32::new(5).expect("not zero")); + windows.created(viewport("Untitled-2"), Some(RawWindowHandle::Xcb(xcb))); + windows.created(viewport("Color"), xlib(7)); + assert_eq!(windows.x11(), [5, 7, 9]); + windows.destroyed(viewport("Color")); + assert_eq!(windows.x11(), [5, 9]); + // A viewport that eframe has already dropped, or never made, is no error. + windows.destroyed(viewport("Color")); + windows.destroyed(viewport("Swatches")); + assert_eq!(windows.x11(), [5, 9]); + } + + /// A window made again for a viewport replaces its record, and one of another window system, or + /// one whose handle could not be read, removes it. + #[cfg(target_os = "linux")] + #[test] + fn a_window_made_again_replaces_its_record() { + let windows = ChildWindows::default(); + windows.created(viewport("Layers"), xlib(9)); + windows.created(viewport("Layers"), xlib(11)); + assert_eq!(windows.x11(), [11]); + let surface = std::ptr::NonNull::::dangling(); + let wayland = winit::raw_window_handle::WaylandWindowHandle::new(surface); + windows.created(viewport("Layers"), Some(RawWindowHandle::Wayland(wayland))); + assert!(windows.x11().is_empty()); + windows.created(viewport("Layers"), xlib(11)); + windows.created(viewport("Layers"), None); + assert!(windows.x11().is_empty()); + } +} diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 1994569c0c..54fa01da03 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -515,21 +515,3 @@ fn the_linux_desktop_entry_opens_every_readable_format() { } } } - -/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose -/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go. -#[cfg(not(any(target_os = "macos", windows)))] -#[test] -fn quitting_hides_the_window_before_the_clipboards_let_go() { - struct Window(std::cell::Cell); - impl QuittingWindow for Window { - fn hide(&self) { - self.0.set(self.0.get() + 1); - } - } - let window = Window(std::cell::Cell::new(0)); - hide_before_teardown(Some(&window)); - assert_eq!(window.0.get(), 1, "the window was not hidden"); - // Without a window (none was made), there is nothing to hide. - hide_before_teardown::(None); -} diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 080462d0b6..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; @@ -351,7 +352,8 @@ pub enum Direction { #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "mode")] pub enum ArtScale { - /// Keep the art's proportions (length follows the path; width scales with it). + /// Keep the art's proportions (length follows the path; width scales with it, times the + /// stroke weight and the brush width). Proportional, /// Stretch the art to the path length. #[default] @@ -798,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 2d62a1a993..de178c76ac 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -141,6 +141,50 @@ fn art_brush_proportional_keeps_aspect() { assert!(near(bounds(&out).height(), 8.0, 1e-6)); } +/// Scale Proportionately art is scaled across by the stroke weight and the Width, as Stretch art +/// is, on top of the scale the path length gives it. +#[test] +fn art_brush_proportional_grows_with_weight() { + let brush = |width: f64, overlap: bool| { + let mut b = art_brush(rect_art(100.0, 4.0), ArtScale::Proportional); + if let BrushKind::Art(a) = &mut b.kind { + (a.width, a.overlap) = (width, overlap); + } + b + }; + let draw = |b: &Brush, bp: &BezPath, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, bp, st); + // 3 pt stroke → 3× wider than at 1 pt (8 pt); the length still follows the path. + let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0))); + assert!(near(b.height(), 24.0, 1e-6) && near(b.x0, 0.0, 1e-6) && near(b.x1, 200.0, 1e-6), "{b:?}"); + // The Width (50 %) applies on top of the stroke weight. + let b = bounds(&draw(&brush(50.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0))); + assert!(near(b.height(), 12.0, 1e-6), "{b:?}"); + // A path twice as long at twice the weight (as Scale Strokes & Effects at 200 % leaves it): + // 2× from the length and 2× from the weight. + let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 400.0, 0.0), &stroke(2.0))); + assert!(near(b.height(), 32.0, 1e-6), "{b:?}"); + // With Overlap, each run between corners is scaled by its own length and by the weight: the + // 100 pt run 4 pt × 3 across, the 50 pt run 2 pt × 3. + let mut bent = BezPath::new(); + bent.move_to((0.0, 0.0)); + bent.line_to((100.0, 0.0)); + bent.line_to((100.0, 50.0)); + let out = draw(&brush(100.0, true), &bent, &stroke(3.0)); + assert_eq!(out.len(), 2); + let (r0, r1) = (out[0].geometric_bounds().unwrap(), out[1].geometric_bounds().unwrap()); + assert!(near(r0.height(), 12.0, 1e-6) && near(r1.width(), 6.0, 1e-6), "{r0:?} {r1:?}"); + // A width that follows the pen: 50 % at the lightest pressure, 300 % at the heaviest, times + // the 2 pt weight (4 pt art on a 100 pt path: 4 pt across at 100 %, 1 pt either side). + let mut pen = brush(50.0, false); + if let BrushKind::Art(a) = &mut pen.kind { + (a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0)); + } + let ramp = StrokeLayer { width: 2.0, ..pressed(vec![(0.0, 0.0), (1.0, 1.0)]) }; + let out = draw(&pen, &line(0.0, 0.0, 100.0, 0.0), &ramp); + assert!(near(half_width(&out, 0.0, 1.0), 2.0, 0.1), "{}", half_width(&out, 0.0, 1.0)); + assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0)); +} + #[test] fn art_brush_between_guides_keeps_ends() { // Head (last 20 % of a 100 pt art) keeps its 20 pt length on a 300 pt path. @@ -973,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/brush/src/warp.rs b/crates/brush/src/warp.rs index c72e1c33f8..edf0ac0567 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -465,17 +465,8 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> { bounds.windows(2).map(|w| (w[0], w[1])).collect() } -/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1, and -/// `width` the width (%) there. -fn art_on_run( - t: &Track, - (s0, s1): (f64, f64), - a: &ArtBrush, - (art, b): (&Node, Rect), - cross: f64, - width: f64, - widen: &dyn Fn(f64) -> f64, -) -> Option { +/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1. +fn art_on_run(t: &Track, (s0, s1): (f64, f64), a: &ArtBrush, (art, b): (&Node, Rect), cross: f64, widen: &dyn Fn(f64) -> f64) -> Option { let l = s1 - s0; if l <= 1e-9 { return None; @@ -483,10 +474,8 @@ fn art_on_run( let w = b.width(); let (along, cross) = match a.scale { ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross), - ArtScale::Proportional => { - let k = l / w; - (AlongMap::linear(w, s0, s1), k * width / 100.0) - } + // Scaled across as much as along, times the stroke weight and the width, as the other modes are. + ArtScale::Proportional => (AlongMap::linear(w, s0, s1), l / w * cross), ArtScale::BetweenGuides { start, end } => { let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w); let (g0, g1) = (g0.min(g1), g0.max(g1)); @@ -528,7 +517,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer) }; let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] }; for run in runs { - if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, width, &widen) { + if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, &widen) { out.push(n); } } diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 66a6a36baa..bb752267aa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -428,7 +428,9 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result { if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { return Err("the export region is not finite".into()); } - let baked = vectorcraft_effects::bake_document(doc); + // Symbols' art is placed without clipping, as the scene places it. + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, false, vectorcraft_effects::WrittenLayers { hidden: true, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); Ok(scene::Scene::new(doc, opts).run()) } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index a14af8d929..84e547709d 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1410,6 +1410,29 @@ fn a_stained_instance_is_written_in_its_stained_color() { assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}"); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // A line and the symbol's line below it, each moved 100 pt right by a Transform effect, start + // at the same x. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + n + }; + let mut d = doc_with(|d| { + let line = path(d, shapes::line((10.0, 10.0).into(), (20.0, 10.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + vec![moved(line), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })] + }); + let art = path(&mut d, shapes::line((10.0, 40.0).into(), (20.0, 40.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let starts: Vec = entities(&export(&d, &opts(&d)).unwrap()).iter().map(|e| all(e, 10).into_iter().fold(f64::INFINITY, f64::min)).collect(); + assert_eq!(starts.len(), 2, "{starts:?}"); + assert!((starts[0] - starts[1]).abs() < 1e-6, "{starts:?}"); +} + #[test] fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty 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/gradient.rs b/crates/color/src/gradient.rs index 54cb50c197..05b7f4839c 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -547,6 +547,49 @@ impl Gradient { None => (Color::BLACK, 1.0), } } + /// Which of its stops color some part of it that draws, by the stretches [`Self::sample`] + /// mixes in, for stops in any order: the pair of stop `k` and stop `k + 1` covers the offsets + /// above every offset of stops `0..=k` up to stop `k + 1`'s, and both its stops count when that + /// stretch has positive width and either stop is above opacity 0. The first stop also counts + /// when it is above opacity 0, since it colors the offsets up to its own, and so does the last + /// stop, since it colors the offsets above every stop's. Any other stop, opaque or not, counts + /// only through a pair: a stop out of order whose pairs have no width (another stop before it + /// reaches past both ends) colors nothing. A freeform gradient draws from its points, so each of + /// its stops counts while any stop is above opacity 0. + pub fn drawn_stops(&self) -> Vec { + self.stops_with_color(|s| s.opacity > 0.0) + } + /// [`Self::drawn_stops`] with every stop taken as opaque, for output that leaves stop opacity + /// out: the stops that color a stretch of positive width, and the first and last stops. + pub fn reached_stops(&self) -> Vec { + self.stops_with_color(|_| true) + } + /// [`Self::drawn_stops`] with `shows` telling whether a stop's opacity shows its color. + fn stops_with_color(&self, shows: impl Fn(&GradientStop) -> bool) -> Vec { + let stops = &self.stops; + if self.kind == GradientKind::Freeform { + let any = stops.iter().any(&shows); + return vec![any; stops.len()]; + } + let mut drawn = vec![false; stops.len()]; + if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) { + *d = shows(first); + } + if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) { + *d |= shows(last); + } + // `f32::max` skips a NaN offset, which the sampler never picks either. + let mut reach = f32::NEG_INFINITY; + for k in 0..stops.len().saturating_sub(1) { + reach = reach.max(stops[k].offset); + let (a, b) = (&stops[k], &stops[k + 1]); + if b.offset > reach && (shows(a) || shows(b)) { + drawn[k] = true; + drawn[k + 1] = true; + } + } + drawn + } /// [`Self::sample_with`] at each of `ts`, which must not decrease. Each search starts where /// the one before it stopped, so the work is the samples plus the stops, not their product. pub fn sample_increasing_with(&self, ts: impl IntoIterator, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> { diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index cd25753a05..16d8005786 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -66,6 +66,69 @@ fn stop_reach_matches_scanning_the_stops() { } } +/// A gradient's stops color what draws where they color a stretch that draws, for stops in any +/// order: the sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, +/// so an out-of-order stop's pair can have no width. +#[test] +fn drawn_stops_follow_the_sampled_stretches() { + let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) }; + let g = |stops: Vec| Gradient::new(GradientKind::Linear, stops); + // In order: the clear stop next to the opaque one, not the one after it. + assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, true, false]); + // Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with + // the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8. + assert_eq!(g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)]).drawn_stops(), vec![false, false, true, true]); + // An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the + // pairs next to the stop at 0.3 have no width and it colors nothing. + let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]); + assert_eq!(out_of_order(0.0).drawn_stops(), vec![false; 5]); + // With the stop at 0.8 opaque, the pairs next to it with width are drawn. + assert_eq!(out_of_order(1.0).drawn_stops(), vec![true, true, false, false, false]); + // The last stop colors the offsets above every stop's, even when it is out of order. + assert_eq!(g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)]).drawn_stops(), vec![false, false, true]); + // The first stop colors the offsets up to its own. + assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, false, false]); + // One stop, every stop clear, and no stops at all. + assert_eq!(g(vec![stop(0.5, 1.0)]).drawn_stops(), vec![true]); + assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![false, false]); + assert!(g(vec![]).drawn_stops().is_empty()); + // A freeform gradient draws from its points: its stops count while any is above opacity 0. + let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]); + free.kind = GradientKind::Freeform; + assert_eq!(free.drawn_stops(), vec![true, true, true]); + free.stops.iter_mut().for_each(|s| s.opacity = 0.0); + assert_eq!(free.drawn_stops(), vec![false; 3]); +} + +/// Taken as opaque, every stop that colors a stretch of positive width counts, and the first and +/// last stops, whatever their opacity; an out-of-order stop whose pairs have no width does not. +#[test] +fn reached_stops_leave_out_only_stops_without_width() { + let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) }; + let g = |stops: Vec| Gradient::new(GradientKind::Linear, stops); + // Clear stops in order color their stretches once opaque. + assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true; 3]); + assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true, true]); + // The stop at 0.3 sits inside the stretch the stops at 0 and 0.8 cover. + let out_of_order = g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]); + assert_eq!(out_of_order.reached_stops(), vec![true, true, false, true, true]); + // A stop at the offset of the stop before it: its pair has no width, the next one does. + assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 1.0), stop(1.0, 1.0)]).reached_stops(), vec![true, true, true]); + assert_eq!( + g(vec![stop(0.0, 1.0), stop(0.6, 1.0), stop(0.6, 1.0), stop(0.2, 1.0), stop(1.0, 1.0)]).reached_stops(), + vec![true, true, false, true, true] + ); + assert!(g(vec![]).reached_stops().is_empty()); + // Every pair the sampler picks between the ends has both its stops counted. + let stops: Vec = [0.1, 0.4, 0.4, 0.2, 0.9, 0.5].iter().map(|&o| stop(o, 1.0)).collect(); + let g = g(stops); + let (reached, reach) = (g.reached_stops(), StopReach::new(&g.stops)); + for t in (5..=95).map(|k| k as f32 / 100.0).filter(|&t| t > 0.1 && t <= 0.9) { + let k = reach.0.partition_point(|r| *r < t); + assert!(reached[k] && reached[k + 1], "pair {k} at {t}: {reached:?}"); + } +} + /// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as /// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one /// offset used to scan them all for each midpoint). 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/appearance.rs b/crates/doc/src/appearance.rs index ba0f5fc013..3bfba32f6a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -271,6 +271,16 @@ impl Appearance { _ => None, }) } + /// Does any fill draw: one that is shown, has paint and has an opacity above 0, wherever it + /// sits in the stack? + pub fn has_drawn_fill(&self) -> bool { + self.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() && f.opacity > 0.0)) + } + /// Does any stroke draw: one that is shown, has paint, a weight above 0 and an opacity above + /// 0, wherever it sits in the stack? + pub fn has_drawn_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.width > 0.0 && s.opacity > 0.0) + } /// Largest distance the painted area can extend beyond any geometry (see /// [`StrokeLayer::reach`]); paths measure their own with [`Self::stroked_bounds`]. pub fn outset(&self) -> f64 { diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 22e5659051..cb5f8fed66 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -79,6 +79,28 @@ fn basic_means_one_plain_fill_and_stroke() { assert!(!e.is_basic(), "an effect"); } +#[test] +fn drawn_fills_and_strokes_count_every_row() { + let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p)); + let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w)); + let black = || Paint::solid(Color::BLACK); + assert!(!Appearance::default().has_drawn_fill() && !Appearance::default().has_drawn_stroke()); + // A painted lower row draws under an unpainted top row. + let a = Appearance { items: vec![fill(black()), stroke(black(), 2.0), fill(Paint::None), stroke(Paint::None, 1.0)], ..Appearance::default() }; + assert!(a.has_drawn_fill() && a.has_drawn_stroke()); + // A hidden painted row, a zero opacity or a zero weight draws nothing. + let mut b = Appearance { items: vec![fill(black()), stroke(black(), 2.0)], ..Appearance::default() }; + b.fill_mut().unwrap().visible = false; + b.stroke_mut().unwrap().visible = false; + assert!(!b.has_drawn_fill() && !b.has_drawn_stroke()); + let mut c = Appearance::default_art(); + c.fill_mut().unwrap().opacity = 0.0; + c.stroke_mut().unwrap().opacity = 0.0; + assert!(!c.has_drawn_fill() && !c.has_drawn_stroke()); + let d = Appearance::basic(Paint::None, black(), 0.0); + assert!(!d.has_drawn_fill() && !d.has_drawn_stroke()); +} + #[test] fn set_creates_missing() { let mut a = Appearance::default(); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index b84de1cac3..2c269766d5 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)) } }) @@ -606,9 +627,8 @@ fn reoriented(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wr out } -/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap -/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its -/// arrowheads. +/// A stroke read along its subpath turned as `o` says: its width profile (whose sides swap when +/// reversed), pen pressure, tilt, bearing and rotation. Its arrowheads stay as they are. fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { if o.is_identity() { return; @@ -648,15 +668,45 @@ fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same); p.points = out; } - if o.reversed && !o.closed { - std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); - s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); +} + +/// `n` with every stroke read along its subpaths turned as `o` says, and on a reversed open path +/// its arrowheads swapped. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o); + if o.reversed && !o.closed { + std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); + s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); + } + } } } -/// `n` with every stroke read along its subpaths turned as `o` says. -fn reorient_strokes(n: &mut Node, o: Orient) { - if o.is_identity() { +/// Turn the strokes of path `n` for Reverse Path Direction, before its subpaths flagged in +/// `reversed` are reversed with [`SubPath::reverse`] (a closed one then starts at its last +/// anchor): each stroke's width profile (sides swapped), pen pressure, tilt, bearing and rotation +/// then run the new way, so the stroke draws as it did. A stroke reads one profile along every +/// subpath, so it turns only when all of them turn alike. Arrowheads stay at the path's start and +/// end. +pub fn reverse_stroke_records(n: &mut Node, reversed: &[bool]) { + let NodeKind::Path { path, .. } = &n.kind else { return }; + let mut turns = path.subpaths.iter().enumerate().map(|(i, sp)| { + let mut o = Orient::of(sp); + if reversed.get(i).copied().unwrap_or(false) { + if sp.closed { + o.start_at(sp, sp.anchors.len().saturating_sub(1)); + } + o.reverse(); + } + o + }); + let Some(o) = turns.next() else { return }; + if o.is_identity() || !turns.all(|t| t.same_turn(&o)) { return; } for it in n.appearance.items.iter_mut() { @@ -2462,6 +2512,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 e89d63cb54..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 @@ -515,9 +530,10 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), /// and grayscale with extra samples, of which the first is the alpha when the file says so (other -/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be -/// stored together or one plane per channel. `None` for other images and ones larger than -/// [`MAX_CMYK_PIXELS`]. +/// channels are left out). Associated (premultiplied) alpha is divided back out, and a pixel with +/// no associated alpha has no color. 16-bit samples are converted at 16 bits, and only the result +/// is rounded to 8 bits. The samples may be stored together or one plane per channel. `None` for +/// other images and ones larger than [`MAX_CMYK_PIXELS`]. pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { use tiff::tags::Tag; let (kind, mut d) = tiff(bytes)?; @@ -535,20 +551,33 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) _ => return None, }; let n = usize::try_from(pixels(w, h)?).ok()?; - let data = read_samples(&mut d, n, samples)?; - let mut out = Vec::with_capacity(n * 4); - for p in data.chunks_exact(samples) { - let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX }; - let a = f32::from(opacity) / 255.0; - let level = |v: u8| { - let v = f32::from(v) / 255.0; - if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v } + let data = read_samples_at_depth(&mut d, n, samples)?; + // Sample `i` of the image, 0..1. + let level_at = |i: usize| match &data { + Samples::Eight(v) => v.get(i).map(|&s| f32::from(s) / 255.0), + Samples::Sixteen(v) => v.get(i).map(|&s| f32::from(s) / 65535.0), + }; + // Sample `i` of the image as 8 bits, a 16-bit sample rounded to the nearest. + let byte_at = |i: usize| match &data { + Samples::Eight(v) => v.get(i).copied(), + Samples::Sixteen(v) => v.get(i).map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)), + }; + let mut out = Vec::with_capacity(n.checked_mul(4)?); + for first in (0..n).map(|p| p * samples) { + let (opacity, a) = if alpha { (byte_at(first + colour).unwrap_or(u8::MAX), level_at(first + colour).unwrap_or(1.0)) } else { (u8::MAX, 1.0) }; + let level = |s: usize| { + let v = level_at(first + s).unwrap_or(0.0); + match extra { + Some(1) if a > 0.0 => (v / a).min(1.0), + // No associated alpha: the color is zero (TIFF 6.0, Associated Alpha Handling). + Some(1) if alpha => 0.0, + _ => v, + } }; - let [r, g, b] = match *p { - [c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]), - [v, ..] if photometric == Some(0) => [1.0 - level(v); 3], - [v, ..] => [level(v); 3], - [] => continue, + let [r, g, b] = match colour { + 4 => rgb([level(0), level(1), level(2), level(3)]), + _ if photometric == Some(0) => [1.0 - level(0); 3], + _ => [level(0); 3], }; out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); out.push(opacity); @@ -767,6 +796,85 @@ mod tests { assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque"); } + /// Five 16-bit samples a pixel: the four inks and one extra sample, which the file's + /// `ExtraSamples` names. + struct Cmyka16; + impl tiff::encoder::colortype::ColorType for Cmyka16 { + type Inner = u16; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::CMYK; + const BITS_PER_SAMPLE: &'static [u16] = &[16; 5]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 5]; + fn horizontal_predict(row: &[u16], result: &mut Vec) { + result.extend_from_slice(row); + } + } + + /// Two 16-bit samples a pixel: gray and one extra sample. + struct GrayExtra16; + impl tiff::encoder::colortype::ColorType for GrayExtra16 { + type Inner = u16; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero; + const BITS_PER_SAMPLE: &'static [u16] = &[16, 16]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2]; + fn horizontal_predict(row: &[u16], result: &mut Vec) { + result.extend_from_slice(row); + } + } + + /// A one-row TIFF of `C`'s pixels `data`, with the ExtraSamples value `extra`. + fn extra_tiff>(extra: u16, data: &[u16]) -> Vec { + let samples = C::BITS_PER_SAMPLE.len(); + let mut file = Cursor::new(Vec::new()); + let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap(); + let mut img = enc.new_image::(u32::try_from(data.len() / samples).unwrap(), 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap(); + img.write_data(data).unwrap(); + file.into_inner() + } + + /// 16-bit CMYK with alpha converts its 16-bit inks and alpha, and rounds only the result to 8 + /// bits; the high byte of each sample alone would give other pixels. + #[test] + fn a_16_bit_cmyk_tiff_with_alpha_converts_at_16_bits() { + let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; + let read = |extra: u16, data: &[u16]| extra_samples_tiff_rgba(&extra_tiff::(extra, data), naive).expect("CMYKA reads").into_raw(); + // Unassociated: 129 of cyan, opaque; no ink at an alpha of 129; no ink at an alpha of 386. + let straight = [129, 0, 0, 0, 65535, 0, 0, 0, 0, 129, 0, 0, 0, 0, 386]; + assert_eq!(read(2, &straight), [254, 255, 255, 255, 255, 255, 255, 1, 255, 255, 255, 2]); + // Associated: cyan 128 at an alpha of 256 (half cyan); K 255 at an alpha of 511 (about half K). + let premultiplied = [128, 0, 0, 0, 256, 0, 0, 0, 255, 511]; + assert_eq!(read(1, &premultiplied), [128, 255, 255, 1, 128, 128, 128, 2], "divided by the 16-bit alpha"); + } + + /// 16-bit gray with alpha converts at 16 bits as well. + #[test] + fn a_16_bit_grayscale_tiff_with_alpha_converts_at_16_bits() { + let read = |extra: u16| { + // A gray of 129, opaque, then 128 at an alpha of 256. + let bytes = extra_tiff::(extra, &[129, 65535, 128, 256]); + extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with alpha reads").into_raw() + }; + assert_eq!(read(2), [1, 1, 1, 255, 0, 0, 0, 1], "unassociated"); + assert_eq!(read(1), [1, 1, 1, 255, 128, 128, 128, 1], "associated, divided by the 16-bit alpha"); + } + + /// With associated alpha, a pixel whose alpha is zero has no color (TIFF 6.0, Associated + /// Alpha Handling), whatever its color samples hold. + #[test] + fn a_pixel_with_no_associated_alpha_has_no_color() { + use tiff::encoder::{TiffEncoder, colortype::CMYKA8}; + let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; + let mut file = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut file).unwrap(); + let mut img = enc.new_image::(1, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap(); + img.write_data(&[255, 0, 0, 0, 0]).unwrap(); + let cmyk = extra_samples_tiff_rgba(&file.into_inner(), naive).expect("CMYKA reads"); + assert_eq!(cmyk.into_raw(), [255, 255, 255, 0], "no ink"); + let gray = extra_samples_tiff_rgba(&extra_tiff::(1, &[50_000, 0]), |_| [0.0; 3]).expect("gray reads"); + assert_eq!(gray.into_raw(), [0, 0, 0, 0], "a gray of zero"); + } + /// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel, /// times 2 planes. #[test] diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 63b7e0ce7c..c846dfc1c6 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -251,6 +251,45 @@ fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool { heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH)) } +/// How deep the art of a document's symbols and patterns draws, to measure objects that use them +/// outside the document's layers ([`Self::deepest`]). +#[derive(Clone, Debug, Default)] +pub struct DrawnHeights { + symbols: HashMap, + patterns: HashMap, +} + +impl DrawnHeights { + /// The heights of `doc`'s symbols and patterns, as [`Document::too_deep`] counts them: the art + /// is 1 deep, and what it uses is counted under it. A symbol or pattern that uses itself, or + /// draws deeper than [`MAX_DRAWN_DEPTH`], has none. + pub fn of(doc: &Document) -> Self { + let mut out = Self::default(); + for ((pattern, name), h) in heights(doc) { + if let Some(h) = h { + let map = if pattern { &mut out.patterns } else { &mut out.symbols }; + map.insert(name.to_string(), h); + } + } + out + } + + /// The deepest `n`, placed `depth` deep, draws: its own objects and the art of their opacity + /// masks, and under the objects the art of the symbols and patterns they use. A use of a + /// symbol or pattern without a height ([`Self::of`]) counts as the object alone. `n`'s own + /// nesting may pass [`MAX_DEPTH`]. Without recursion. + pub fn deepest(&self, n: &Node, depth: usize) -> usize { + let mut out = depth; + let mut stack = vec![(n, depth)]; + while let Some((n, d)) = stack.pop() { + let used = uses(n).filter_map(|(pattern, name)| if pattern { self.patterns.get(name) } else { self.symbols.get(name) }).max(); + out = out.max(d + used.copied().unwrap_or(0)); + stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1))); + } + out + } +} + /// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object): /// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees /// within [`MAX_DEPTH`]. @@ -623,4 +662,23 @@ mod tests { d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")])); assert!(!d.too_deep(None) && !d.limit_depth()); } + + #[test] + fn drawn_heights_measure_objects_outside_the_layers() { + // s0 holds s1 two groups deep, which holds s2, which holds an instance of nothing known: + // s2 draws 3 deep, s1 6 and s0 9. + let d = symbols(3, 2, "none"); + let heights = DrawnHeights::of(&d); + let on_layer = instance(1, "s0"); + assert_eq!(heights.deepest(&on_layer, 2), 2 + 9); + // An object's own nesting and its mask's art count past MAX_DEPTH. + let mut deep = Node::group(NodeId(5), vec![nested(MAX_DEPTH + 10)]); + deep.extras_mut().mask = Some(Box::new(OpacityMask::new((*instance(6, "s2")).clone(), true))); + assert_eq!(heights.deepest(&deep, 1), 1 + 1 + MAX_DEPTH + 10); + let shallow = Node::group(NodeId(5), vec![instance(7, "s1")]); + assert_eq!(heights.deepest(&shallow, 100), 101 + 6); + // A symbol that holds itself has no height, so its instance counts alone. + let looped = DrawnHeights::of(&symbols(2, 1, "s0")); + assert_eq!(looped.deepest(&instance(1, "s0"), 2), 2); + } } 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..c286492236 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -61,6 +61,7 @@ pub mod range; pub mod rastersettings; mod reach; pub mod recolor; +pub mod resample; pub mod selection; pub mod separated; pub mod setup; @@ -367,6 +368,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..80740b7cc2 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -229,7 +229,8 @@ fn proxy_png(bytes: &[u8]) -> Option> { /// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG. fn proxy_of(img: &image::DynamicImage) -> Option> { - let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE); + let (w, h) = crate::resample::fit_size(img.width(), img.height(), PROXY_SIZE, PROXY_SIZE); + let small = crate::resample::averaged(img, w, h); let mut png = Vec::new(); small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; Some(png) @@ -248,14 +249,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 +386,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 +412,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 +475,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); } @@ -728,4 +733,19 @@ mod tests { image(&mut d, "c", None); assert_eq!(d.linked_only_images().into_iter().collect::>(), ["a"]); } + + /// The preview of an image with clear pixels keeps the color of its opaque ones: the clear + /// black beside them does not darken them, whichever way the preview is made. + #[test] + fn a_preview_keeps_the_color_beside_clear_pixels() { + let mut full = vec![]; + image::RgbaImage::from_fn(1024, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut Cursor::new(&mut full), image::ImageFormat::Png) + .unwrap(); + for blob in [ImageBlob::png(full.clone()).with_proxy(), ImageBlob::png(full.clone()).checked_with_proxy(true).unwrap()] { + let p = image::load_from_memory(blob.proxy.as_ref().unwrap()).unwrap().into_rgba8(); + assert_eq!(p.dimensions(), (PROXY_SIZE, 1)); + assert!(p.pixels().all(|q| q.0 == [200, 30, 30, 128]), "{:?}", p.get_pixel(0, 0)); + } + } } 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/resample.rs b/crates/doc/src/resample.rs new file mode 100644 index 0000000000..bc72cce1db --- /dev/null +++ b/crates/doc/src/resample.rs @@ -0,0 +1,259 @@ +//! Scaling images whose pixels carry straight (not premultiplied) alpha. +//! +//! `image`'s filtered resampling filters each channel on its own, which is correct only for +//! premultiplied pixels, and its thumbnail averages each channel on its own too, so on +//! straight-alpha pixels the color under clear pixels bleeds into the opaque ones next to them +//! (clear black darkens a cut-out's edge). [`resized`], [`fitted`] and [`averaged`] weight each +//! pixel's color by its alpha while scaling, as the streaming shrink in the renderer does. + +use image::imageops::FilterType; +use image::{DynamicImage, RgbaImage}; + +/// How [`resized`] makes each new pixel from the old ones. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Resample { + /// `image`'s filtered resampling with this filter. + Filter(FilterType), + /// The mean of the old pixels each new one covers ([`averaged`]). + Average, +} + +/// `img` scaled to `w` × `h` pixels as `how` says, each pixel's color weighted by its alpha. +/// Filtered or averaged, an image with clear or partly clear pixels comes back as 8-bit RGBA +/// whatever its own pixel format, and a pixel that ends up wholly clear is transparent black. An +/// image without clear pixels is scaled as `image` scales it, in its own pixel format. +/// Nearest-neighbor picking leaves the pixels as they are, in their own format, and is not +/// weighted. Filtered, the colors keep the precision of premultiplied 8-bit pixels, and an 8-bit +/// RGBA image is premultiplied in place. +pub fn resized(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + if (w, h) == (img.width(), img.height()) { + return img; + } + let f = match how { + Resample::Average => return averaged(&img, w, h), + Resample::Filter(f) => f, + }; + if f == FilterType::Nearest || opaque(&img) { + return img.resize_exact(w, h, f); + } + let mut rgba = img.into_rgba8(); + for p in rgba.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = times_alpha(*c, *a); + } + } + let mut out = image::imageops::resize(&rgba, w, h, f); + for p in out.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = over_alpha(*c, *a); + } + } + DynamicImage::ImageRgba8(out) +} + +/// [`resized`] to the largest size that fits `w` × `h` pixels and keeps `img`'s proportions, the +/// size `image`'s own `resize` and `thumbnail` pick. +pub fn fitted(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + let (nw, nh) = fit_size(img.width(), img.height(), w, h); + resized(img, nw, nh, how) +} + +/// `img` scaled to `w` × `h` pixels, each new pixel the mean of the old pixels it covers, each old +/// pixel counted by the area of it covered and its color weighted by its alpha. `img` is read one +/// row at a time, so besides the result this takes one row of 8-bit RGBA at most. An image +/// without clear pixels is scaled as `image`'s thumbnail scales it. +pub fn averaged(img: &DynamicImage, w: u32, h: u32) -> DynamicImage { + let (sw, sh) = (img.width(), img.height()); + if opaque(img) || w == 0 || h == 0 || sw == 0 || sh == 0 { + return img.thumbnail_exact(w, h); + } + // Per new pixel of the row being made: red, green and blue times alpha, alpha (all times the + // area covered), and the area covered. + let mut sums = vec![([0f64; 4], 0f64); w as usize]; + let mut out = Vec::with_capacity(w as usize * h as usize * 4); + for ty in 0..h { + for (y, wy) in covered(ty, sh, h) { + let row = rgba_row(img, y as u32); + for ((s, area), tx) in sums.iter_mut().zip(0..w) { + for (x, wx) in covered(tx, sw, w) { + let Some(p) = row.get(x * 4..x * 4 + 4) else { continue }; + let k = wx * wy; + let a = f64::from(p[3]) * k; + s[0] += f64::from(p[0]) * a; + s[1] += f64::from(p[1]) * a; + s[2] += f64::from(p[2]) * a; + s[3] += a; + *area += k; + } + } + } + let byte = |v: f64| v.round().clamp(0.0, 255.0) as u8; + for (s, area) in &mut sums { + out.extend(if s[3] <= 0.0 { [0; 4] } else { [byte(s[0] / s[3]), byte(s[1] / s[3]), byte(s[2] / s[3]), byte(s[3] / *area)] }); + (*s, *area) = ([0.0; 4], 0.0); + } + } + RgbaImage::from_raw(w, h, out).map_or_else(|| img.thumbnail_exact(w, h), DynamicImage::ImageRgba8) +} + +/// Row `y` of `img` as 8-bit RGBA: borrowed from an 8-bit RGBA image, converted from others. +fn rgba_row(img: &DynamicImage, y: u32) -> std::borrow::Cow<'_, [u8]> { + let n = img.width() as usize * 4; + match img { + DynamicImage::ImageRgba8(b) => std::borrow::Cow::Borrowed(b.as_raw().get(y as usize * n..(y as usize + 1) * n).unwrap_or_default()), + other => std::borrow::Cow::Owned(other.crop_imm(0, y, other.width(), 1).into_rgba8().into_raw()), + } +} + +/// The old pixels that new pixel `t` of `tn` covers along a side of `n` old pixels, each with the +/// length of it covered (in old pixels), worked out as they are read so that nothing is stored. +fn covered(t: u32, n: u32, tn: u32) -> impl Iterator { + let scale = f64::from(n) / f64::from(tn); + let (a, b) = (f64::from(t) * scale, (f64::from(t) + 1.0) * scale); + let (first, end) = (a.floor() as u32, (b.ceil() as u32).min(n)); + (first..end.max(first + 1).min(n)).filter_map(move |i| { + let len = b.min(f64::from(i) + 1.0) - a.max(f64::from(i)); + (len > 0.0).then_some((i as usize, len)) + }) +} + +/// Each 4-channel pixel of `px` with all four channels multiplied by its value in `alpha` (one +/// sample a pixel, 0 to 255): ink amounts or colors made ready for scaling beside a separate alpha +/// channel. Pixels past the end of `alpha` are left as they are. +pub fn premultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = times_alpha(*c, a); + } + } +} + +/// [`premultiply`] undone after scaling: each 4-channel pixel of `px` divided by its value in the +/// scaled `alpha`, and 0 where that is 0. +pub fn unpremultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = over_alpha(*c, a); + } + } +} + +/// Does `img` have no alpha channel, or is every pixel of it opaque? +fn opaque(img: &DynamicImage) -> bool { + match img { + DynamicImage::ImageRgba8(b) => b.pixels().all(|p| p[3] == u8::MAX), + DynamicImage::ImageLumaA8(b) => b.pixels().all(|p| p[1] == u8::MAX), + DynamicImage::ImageRgba16(b) => b.pixels().all(|p| p[3] == u16::MAX), + DynamicImage::ImageLumaA16(b) => b.pixels().all(|p| p[1] == u16::MAX), + DynamicImage::ImageRgba32F(b) => b.pixels().all(|p| p[3] >= 1.0), + other => !other.color().has_alpha(), + } +} + +/// Channel `c` times alpha `a`, both 0 to 255. +fn times_alpha(c: u8, a: u8) -> u8 { + ((u16::from(c) * u16::from(a) + 127) / 255) as u8 +} + +/// Premultiplied channel `c` divided by alpha `a` (0 when `a` is 0). +fn over_alpha(c: u8, a: u8) -> u8 { + if a == 0 { + return 0; + } + let a = u32::from(a); + ((u32::from(c) * 255 + a / 2) / a).min(255) as u8 +} + +/// The largest size of at most `nw` × `nh` with the proportions of `w` × `h`, at least a pixel a +/// side. +pub fn fit_size(w: u32, h: u32, nw: u32, nh: u32) -> (u32, u32) { + if w == 0 || h == 0 { + return (nw.max(1), nh.max(1)); + } + let ratio = (f64::from(nw) / f64::from(w)).min(f64::from(nh) / f64::from(h)); + let side = |n: u32| ((f64::from(n) * ratio).round() as u32).max(1); + (side(w), side(h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 4 × 2 image whose left half is opaque red and whose right half is clear black. + fn half_clear() -> DynamicImage { + DynamicImage::ImageRgba8(RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })) + } + + /// Is the color of `p`, premultiplied by its alpha, within a step of `rgb` premultiplied by the + /// same alpha (the precision of premultiplied 8-bit pixels)? + fn close(p: [u8; 4], rgb: [u8; 3]) -> bool { + p[..3].iter().zip(rgb).all(|(&c, want)| times_alpha(c, p[3]).abs_diff(times_alpha(want, p[3])) <= 1) + } + + #[test] + fn averaging_keeps_the_color_of_the_opaque_pixels() { + let p = resized(half_clear(), 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0; + assert!(close(p, [200, 30, 30]) && p[3].abs_diff(128) <= 1, "{p:?}"); + } + + /// Three pixels averaged into two: each new pixel takes one old pixel whole and half of the + /// clear one in the middle. + #[test] + fn averaging_counts_the_part_of_a_pixel_covered() { + let img = RgbaImage::from_fn(3, 1, |x, _| if x == 1 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([200, 30, 30, 255]) }); + let out = averaged(&DynamicImage::ImageRgba8(img), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 30, 30, 170], [200, 30, 30, 170]].concat()); + // Gray with alpha is read the same way. + let gray = image::GrayAlphaImage::from_fn(3, 1, |x, _| image::LumaA(if x == 1 { [0, 0] } else { [200, 255] })); + let out = averaged(&DynamicImage::ImageLumaA8(gray), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 200, 200, 170], [200, 200, 200, 170]].concat()); + } + + #[test] + fn filtering_keeps_the_color_of_the_opaque_pixels() { + for f in [FilterType::Triangle, FilterType::CatmullRom, FilterType::Lanczos3] { + let out = resized(half_clear(), 2, 1, Resample::Filter(f)).into_rgba8(); + for x in 0..2 { + let p = out.get_pixel(x, 0).0; + assert!(p[3] == 0 || close(p, [200, 30, 30]), "{f:?} pixel {x}: {p:?}"); + } + } + } + + #[test] + fn fitted_keeps_proportions() { + let img = DynamicImage::ImageRgba8(RgbaImage::new(400, 100)); + let out = fitted(img, 64, 64, Resample::Average); + assert_eq!((out.width(), out.height()), (64, 16)); + } + + #[test] + fn opaque_and_alpha_free_images_scale_as_image_scales_them() { + let rgb = DynamicImage::ImageRgb8(image::RgbImage::from_fn(5, 3, |x, y| image::Rgb([x as u8 * 40, y as u8 * 60, 7]))); + let ours = resized(rgb.clone(), 2, 2, Resample::Filter(FilterType::Triangle)); + assert_eq!(ours, rgb.resize_exact(2, 2, FilterType::Triangle)); + let opaque = DynamicImage::ImageRgba8(rgb.to_rgba8()); + assert_eq!(resized(opaque.clone(), 2, 2, Resample::Average), opaque.thumbnail_exact(2, 2)); + } + + #[test] + fn a_wholly_clear_result_is_transparent_black() { + let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 4, image::Rgba([90, 90, 90, 0]))); + assert_eq!(resized(img, 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0, [0, 0, 0, 0]); + } + + #[test] + fn premultiply_and_unpremultiply_scale_beside_a_separate_alpha() { + // Two pixels of inks, the second clear: averaged, the first one's inks remain. + let mut px = vec![40, 80, 120, 160, 0, 0, 0, 0]; + premultiply(&mut px, &[255, 0]); + let img = RgbaImage::from_raw(2, 1, px).unwrap(); + let mut out = image::imageops::thumbnail(&img, 1, 1).into_raw(); + let alpha = image::imageops::thumbnail(&image::GrayImage::from_raw(2, 1, vec![255, 0]).unwrap(), 1, 1).into_raw(); + unpremultiply(&mut out, &alpha); + let want = [40, 80, 120, 160]; + assert!(out.iter().zip(want).all(|(&c, w)| times_alpha(c, alpha[0]).abs_diff(times_alpha(w, alpha[0])) <= 1), "{out:?} {alpha:?}"); + } +} 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/doc/src/text.rs b/crates/doc/src/text.rs index 440c379283..c3c1464cfc 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -18,7 +18,7 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; pub use char_style::{ - CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 16b63e0809..ef3cf29dc9 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -44,6 +44,14 @@ pub struct CharStyle { /// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`]. #[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")] pub aki_right: f64, + /// Tsume (Character panel, East Asian options): the percentage of the blank space on each side + /// of the character's ink, inside its own width, that is taken away, 0 to [`MAX_TSUME`]. 50 + /// halves that space and 100 sets the ink edge to edge; the glyph itself is not scaled. A + /// cluster takes the value of its first character. Tracking, kerning and Insert Aki are added + /// after it. Horizontal type only. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub tsume: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -440,6 +448,9 @@ fn hundred() -> f64 { /// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em. pub const MAX_AKI: f64 = 1000.0; +/// The largest Tsume ([`CharStyle::tsume`]), in percent: all the blank space beside the ink. +pub const MAX_TSUME: f64 = 100.0; + /// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]). pub const WARICHU_LINES: std::ops::RangeInclusive = 1..=6; @@ -534,6 +545,7 @@ impl Default for CharStyle { kerning_before: 0.0, aki_left: 0.0, aki_right: 0.0, + tsume: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, @@ -588,7 +600,8 @@ impl CharStyle { /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right /// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to - /// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale + /// [`MAX_AKI`], and one that is not a number becomes 0; Tsume is kept from 0 to [`MAX_TSUME`] the + /// same way. The Warichu Settings Lines and Scale /// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to /// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its /// own units. @@ -606,6 +619,7 @@ impl CharStyle { let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) }; self.aki_left = aki(self.aki_left); self.aki_right = aki(self.aki_right); + self.tsume = if self.tsume.is_nan() { 0.0 } else { self.tsume.clamp(0.0, MAX_TSUME) }; self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end()); self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) }; self.warichu_scale = diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index dd4002dd60..9af4d9c5dc 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -507,6 +507,7 @@ impl crate::Document { st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); st.aki_left = num("akiLeft").unwrap_or(st.aki_left); st.aki_right = num("akiRight").unwrap_or(st.aki_right); + st.tsume = num("tsume").unwrap_or(st.tsume); st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap); st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale); st.keep_dimensions_in_range(); @@ -532,6 +533,7 @@ impl crate::Document { ("tcyLeftRight", Some(st.tcy_left_right)), ("akiLeft", Some(st.aki_left)), ("akiRight", Some(st.aki_right)), + ("tsume", Some(st.tsume)), ("leading", st.leading), ]; for (k, v) in kept { diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index b8531b402d..26622fa115 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -357,7 +357,7 @@ impl ParaList { /// The style the marker is set in: `first` (the style of the paragraph's first character) /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough. + /// Insert Aki, Tsume, tate-chu-yoko, warichu, underline and strikethrough. pub fn marker_style(first: &CharStyle) -> CharStyle { CharStyle { baseline_shift: 0.0, @@ -368,6 +368,7 @@ impl ParaList { kerning_before: 0.0, aki_left: 0.0, aki_right: 0.0, + tsume: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, warichu: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 0f474d2ed8..c3afafb6e1 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -129,6 +129,34 @@ fn insert_aki_is_saved_only_when_set() { assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0))); } +/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a +/// value out of range from a file, in a run or a character style, is kept from 0 to 100. +#[test] +fn tsume_is_saved_only_when_set_and_kept_in_range() { + let st = CharStyle::default(); + assert_eq!(st.tsume, 0.0); + let saved = serde_json::to_value(&st).unwrap(); + assert!(saved.get("tsume").is_none()); + let read = serde_json::from_value::(saved).unwrap(); + assert_eq!(read.tsume, 0.0); + let set = CharStyle { tsume: 37.5, ..CharStyle::default() }; + let saved = serde_json::to_value(&set).unwrap(); + assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5)); + assert_eq!(serde_json::from_value::(saved).unwrap(), set); + for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] { + let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() }; + damaged.keep_dimensions_in_range(); + assert_eq!(damaged.tsume, kept, "{stored}"); + } + let mut d = crate::Document::new(100.0, 100.0); + for tsume in [500, -1] { + let attrs = serde_json::json!({ "tsume": tsume }); + d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() }); + } + d.keep_type_values_in_range(); + assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0))); +} + /// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its /// script's block; every system saves under its id, which reads back, and other names are refused. #[test] diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2cffad6581..d648dd9258 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -17,7 +17,8 @@ //! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by //! the caller). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use std::sync::Arc; use vectorcraft_doc::color::Paint; @@ -26,20 +27,20 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, }; -fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { +pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { match item { AppearanceItem::Fill(f) => &f.effects, AppearanceItem::Stroke(s) => &s.effects, } } -fn clear_item_effects(item: &mut AppearanceItem) { +pub(crate) fn clear_item_effects(item: &mut AppearanceItem) { match item { AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), @@ -74,7 +75,7 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> { /// `path` with the geometry effects of `effects` applied, and how gradients follow them /// ([`crate::Carry`]). -fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { +pub(crate) fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { if !has_geometry(effects) || path.is_empty() { return (path.clone(), crate::Carry::default()); } @@ -86,7 +87,7 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext /// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient /// moved along with them and the Transform effects the geometry pass left out hidden /// ([`crate::Carry::hide_skipped`]). -fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { +pub(crate) fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { let (ig, carry) = apply(item_effects(item), g, &ctx.item(item)); let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() }; if carry.moves(&ap) { @@ -114,13 +115,181 @@ thread_local! { /// The objects of symbol art that [`bake_document`] has baked for instances inside the art of /// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`]. static NESTED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their + /// placed art ([`placed_symbol_art`]). + static PLACING: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// The objects in the baked art of the instances that [`bake_node`] has replaced by their + /// placed art for one export ([`placed_symbol_art`]), counted against [`Placing::most_nodes`]. + static PLACED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The objects in the baked art of the instances that [`bake_node`] baked for one export and + /// then left as they are ([`placed_fits`] refused the art), counted with [`PLACED_NODES`] + /// against [`Placing::most_nodes`] before more art is baked. Never given back. + static DROPPED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer + /// is 1 deep. + static LEVEL: std::cell::Cell = const { std::cell::Cell::new(1) }; + /// Whether [`bake_node`] is baking inside an object the export does not write ([`unwritten`]), + /// where no instance is placed. + static UNWRITTEN: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// `f` run for an object one level deeper ([`LEVEL`]) than the one being baked. +fn one_deeper(f: impl FnOnce() -> T) -> T { + LEVEL.with(|c| c.set(c.get() + 1)); + let out = f(); + LEVEL.with(|c| c.set(c.get().saturating_sub(1))); + out +} + +/// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and +/// how that art is placed through 9-slice guides. +struct Placing { + /// The names of the symbols whose art draws something that baking changes, directly or through + /// the instances of other such symbols in it ([`drawn_symbols`]). + symbols: HashSet, + /// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not + /// ([`Document::place_symbol_art_unclipped`]). + clip_slices: bool, + /// Most objects the baked art of all placed instances may hold together: + /// [`vectorcraft_doc::MAX_SYMBOL_NODES`] for an export. + most_nodes: u64, + /// How deep the document's symbols and patterns draw, for the instances and pattern paints + /// left in placed art. + heights: vectorcraft_doc::depth::DrawnHeights, + /// The hidden layers and layers that do not print that the export writes. + layers: WrittenLayers, +} + +/// The layers an exporter writes besides the shown, printing ones +/// ([`bake_document_placing_symbols`]). Template layers are never written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WrittenLayers { + /// Hidden layers (written turned off, as PDF layers and DXF write them). + pub hidden: bool, + /// Layers that do not print. + pub non_printing: bool, +} + +/// Does the export leave `n` and everything in it out, while [`bake_document_placing_symbols`] +/// bakes? A hidden object other than a layer, a template layer, and a hidden layer or a layer +/// that does not print when the export leaves those out ([`WrittenLayers`]). +fn unwritten(n: &Node) -> bool { + PLACING.with(|p| { + p.borrow().as_ref().is_some_and(|p| match n.kind { + NodeKind::Layer { template, printable, .. } => template || !(printable || p.layers.non_printing) || !(n.visible || p.layers.hidden), + _ => !n.visible, + }) + }) +} + +/// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, +/// when the art placed for the export so far holds `spent` objects and the art baked for +/// instances left as they are holds `dropped` ([`DROPPED_NODES`])? `Some` with the +/// objects placed then, when its objects (opacity masks' art included) fit with those within +/// [`Placing::most_nodes`] and nothing in it, the symbols and patterns it uses included, draws +/// deeper than [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`]. +fn placed_fits(baked: &Node, spent: u64, dropped: u64) -> Option { + PLACING.with(|p| { + let p = p.borrow(); + let p = p.as_ref()?; + let spent = spent.saturating_add(baked.count_with_masks() as u64); + let deepest = p.heights.deepest(baked, LEVEL.with(|c| c.get())); + (spent.saturating_add(dropped) <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) + }) +} + +/// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art +/// holds an instance of one of them at any depth. +fn drawn_symbols(d: &Document) -> HashSet { + let mut out: HashSet = d.symbols.iter().filter(|s| needs_bake(&s.art)).map(|s| s.name.clone()).collect(); + if out.is_empty() { + return out; + } + let uses: Vec<(&str, Vec)> = d + .symbols + .iter() + .filter(|s| !out.contains(&s.name)) + .map(|s| { + let mut names = vec![]; + s.art.walk(&mut |c| { + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + names.push(symbol.clone()); + } + }); + (s.name.as_str(), names) + }) + .collect(); + loop { + let before = out.len(); + for (name, names) in &uses { + if !out.contains(*name) && names.iter().any(|x| out.contains(x)) { + out.insert((*name).to_string()); + } + } + if out.len() == before { + return out; + } + } +} + +/// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces +/// by its placed art? False outside it, and for objects the export does not write ([`unwritten`]). +fn holds_placed_symbol(n: &Node) -> bool { + fn walk(n: &Node, symbols: &HashSet) -> bool { + !unwritten(n) + && (matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) + || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols)))) + } + !UNWRITTEN.with(|c| c.get()) && PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) +} + +/// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it +/// places, as the art it draws: the symbol's art with the instance's own appearance changes and +/// its stain ([`vectorcraft_doc::symbol_overrides::instance_art`]), placed by the instance as the exporters +/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask, +/// raster effects and attributes (its URL among them). The art is placed before its effects are baked, as the canvas draws it, so an +/// instance that scales its art does not scale what its effects do. `None` for other objects, +/// for instances the export does not write ([`unwritten`], hidden or inside such an object), for +/// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art +/// as placed, before its effects are baked, would already take the art placed for the export, +/// with the art baked for instances left as they are ([`DROPPED_NODES`]), past +/// [`Placing::most_nodes`]; those instances stay as they are. `Some` with the objects in the art +/// as placed, before its effects are baked. [`bake_node`] checks the baked art with +/// [`placed_fits`]. +fn placed_symbol_art(d: &Document, n: &Node) -> Option<(Node, u64)> { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || UNWRITTEN.with(|c| c.get()) || unwritten(n) { + return None; + } + let sym = d.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); + let most = match d.symbol_slice_map(symbol, *xf) { + Some(m) if clip => m.most_placed(&art, d), + Some(m) => m.most_placed_unclipped(&art, d), + None => art.count_with_masks() as u64, + }; + if PLACED_NODES.with(|c| c.get()).saturating_add(DROPPED_NODES.with(|c| c.get())).saturating_add(most) > most_nodes { + return None; + } + let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; + let mut g = dressed(Node::group(n.id, vec![Arc::new(placed)]), n); + g.extras_mut().attrs = n.extras().attrs.clone(); + Some((g, most)) } /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { + // Inside an object the export leaves out, effects are baked but no instance is placed. + if !UNWRITTEN.with(|c| c.get()) && unwritten(n) { + UNWRITTEN.with(|c| c.set(true)); + let out = bake_node(d, n); + UNWRITTEN.with(|c| c.set(false)); + return out; + } // A cancelled export leaves the rest of the document as it is: the caller drops the result. // A ruler guide draws nothing and stays as it is, last in its layer. - if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. @@ -181,6 +350,26 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } return Some(baked.unwrap_or(m)); } + // Symbol instances whose art draws live effects, when the caller asks for them: the art + // placed, then baked. When the baked art passes the export's budget, or would draw too deep + // where the instance is, it is dropped with the art placed for the instances inside it, and + // the instance stays as it is. While the art bakes, its objects before baking are held in + // the budget, so the instances inside it are placed only within what is left; art baked and + // then dropped still counts against the budget for the instances after it. + if let Some((m, most)) = placed_symbol_art(d, n) { + let spent = PLACED_NODES.with(|c| c.get()); + PLACED_NODES.with(|c| c.set(spent.saturating_add(most))); + INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); + let baked = bake_node(d, &m); + INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); + let baked = baked.unwrap_or(m); + if let Some(now) = placed_fits(&baked, spent, DROPPED_NODES.with(|c| c.get())) { + PLACED_NODES.with(|c| c.set(now)); + return Some(baked); + } + PLACED_NODES.with(|c| c.set(spent)); + DROPPED_NODES.with(|c| c.set(c.get().saturating_add(baked.count_with_masks() as u64))); + } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning // every copy and its strokes and patterns can differ from the others'. @@ -201,8 +390,26 @@ 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(); - let baked: Vec> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); + // 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`]); + // the pieces it is made of then get ids of their own. + let clip = usize::from(n.clips()); + let baked: Vec> = ch + .iter() + .enumerate() + .map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() { + Some(a) => { + let mut a = one_deeper(|| bake_node(d, &a)).unwrap_or(a); + fresh_ids(d, &mut a, &mut std::collections::HashSet::new()); + Arc::new(a) + } + None => one_deeper(|| bake_node(d, c)).map(Arc::new).unwrap_or_else(|| c.clone()), + }) + .collect(); if let Some(ch) = m.children_mut() { *ch = baked; } @@ -211,6 +418,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. @@ -221,7 +484,7 @@ fn bake_pieces(d: &mut Document, mut m: Node) -> Node { .into_iter() .map(|c| { let c = Arc::unwrap_or_clone(c); - let mut c = bake_node(d, &c).unwrap_or(c); + let mut c = one_deeper(|| bake_node(d, &c)).unwrap_or(c); fresh_ids(d, &mut c, &mut seen); Arc::new(c) }) @@ -407,7 +670,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())), } } - None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + None if clipping => clipping_text_outline(c), None => as_art(Some(d), c, None), }; if let Some(o) = art { @@ -469,7 +732,7 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of /// its effects, and `n` without those effects, its gradients moved along with them. -fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { +pub(crate) fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { let (base, rule) = geometry(n)?; let ctx = GeomContext::of(n); let mut m = n.clone(); @@ -698,8 +961,60 @@ fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes: /// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when /// there is nothing to do (export it as is). pub fn bake_document(doc: &Document) -> Option { + bake(doc, None) +} + +/// [`bake_document`], and every symbol instance whose symbol's art draws something baking changes +/// (live geometry effects, color adjustments and the rest [`needs_bake`] counts, in its own art or +/// in the art of the symbols it places) replaced by that art as the instance places it, baked +/// ([`placed_symbol_art`]), so the art draws as on the canvas wherever the instance is. For +/// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than +/// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped +/// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the +/// exporter places other instances. `layers`: the hidden layers and layers that do not print +/// that the exporter writes. Instances the export does not write (hidden ones, those in hidden +/// objects, on template layers, and on the layers `layers` leaves out) stay as they are. The +/// symbols' own art is left as it is. +pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool, layers: WrittenLayers) -> Option { + bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES, layers))) +} + +/// Restores what [`bake`] changes in the thread's baking state when it ends. +struct Restore(Option>, u64, u64, usize, bool); + +impl Drop for Restore { + fn drop(&mut self) { + PLACING.with(|p| *p.borrow_mut() = self.0.take()); + PLACED_NODES.with(|c| c.set(self.1)); + DROPPED_NODES.with(|c| c.set(self.2)); + LEVEL.with(|c| c.set(self.3)); + UNWRITTEN.with(|c| c.set(self.4)); + } +} + +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`, +/// [`Placing::most_nodes`] and [`Placing::layers`]. +fn bake(doc: &Document, placing: Option<(bool, u64, WrittenLayers)>) -> Option { let placed = doc.with_placed_art(); - if !placed.layers.iter().any(|l| needs_bake(l)) { + let placing = placing + .map(|(clip_slices, most_nodes, layers)| (drawn_symbols(&placed), clip_slices, most_nodes, layers)) + .filter(|p| !p.0.is_empty()) + .map(|(symbols, clip_slices, most_nodes, layers)| Placing { + symbols, + clip_slices, + most_nodes, + heights: vectorcraft_doc::depth::DrawnHeights::of(&placed), + layers, + }); + // A bake inside another one (of art an effect makes) places symbols only when it asks to. + let _restore = Restore( + PLACING.with(|p| p.replace(placing.map(Rc::new))), + PLACED_NODES.with(|c| c.replace(0)), + DROPPED_NODES.with(|c| c.replace(0)), + LEVEL.with(|c| c.replace(1)), + UNWRITTEN.with(|c| c.replace(false)), + ); + if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), std::borrow::Cow::Borrowed(_) => None, diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index f3855f57ca..7b353f78f9 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 { @@ -793,7 +793,7 @@ fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { } #[test] -fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { +fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() { if skip_without_craft_fonts() { return; } @@ -808,9 +808,59 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { g.children_mut().unwrap().push(Arc::new(clip)); let d = bake_document(&doc_with(g)).unwrap(); assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - // The type clips by its plain outlines, as the canvas clips by them: its own effect is left out. + // The type clips by its outlines moved by its own effect, as the canvas clips by them. let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap(); - assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}"); + 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] @@ -1065,3 +1115,345 @@ fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { // Three squares of their own, not one path spanning all three. assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); } + +/// A 10 × 10 square at (10, 40), with id `id`, that a Transform effect moves 100 pt right. +fn moved_square(id: u64) -> Node { + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + n +} + +fn instance(id: u64, symbol: &str, xf: vectorcraft_geom::Affine) -> Node { + Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); +} + +fn bounds(d: &Document, id: u64) -> Rect { + d.node(NodeId(id)).and_then(|n| n.geometric_bounds()).unwrap_or_else(|| panic!("node {id} has bounds")) +} + +fn near_rect(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +/// Each instance draws its symbol's art with the art's effects applied where the instance places +/// it: an instance that doubles the art's size moves it the effect's 100 pt, as the canvas draws +/// it, not 200 pt. Plain baking leaves the instances and the symbols' art as they are. +#[test] +fn placing_symbols_bakes_their_art_where_each_instance_places_it() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap(); + add_symbol(&mut d, "S", moved_square(800)); + assert!(bake_document(&d).is_none(), "nothing in the layers needs baking"); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. })); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900)); + assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901)); + assert_eq!(out.symbols[0].art.appearance.effects.len(), 1, "the symbol's art keeps its effect"); +} + +/// An instance of a symbol whose art holds an instance of another symbol with effects draws that +/// art with its effects too. +#[test] +fn placing_symbols_reaches_the_art_of_nested_instances() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", moved_square(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))])); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900)); +} + +/// A symbol whose art holds an instance of itself is placed until the depth limit, and the export +/// ends. +#[test] +fn placing_a_symbol_that_holds_itself_stops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Loop", Affine::IDENTITY)); + add_symbol( + &mut d, + "Loop", + Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]), + ); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + let mut lefts = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0))); + assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}"); + let mut depth = 0; + let mut n = out.node(NodeId(900)).unwrap(); + while let Some(next) = n.children().and_then(|ch| ch.last()) { + n = next; + depth += 1; + } + assert!(depth <= 3 * (vectorcraft_doc::depth::MAX_DEPTH + 1), "{depth}"); +} + +/// A dynamic symbol instance's own paint for an object of the art applies before the object's +/// effects are baked: a fill whose own Transform effect makes the square a group of pieces still +/// paints in the instance's red, and its sibling keeps the symbol's paint. +#[test] +fn placing_symbols_keeps_dynamic_instances_changes() { + use vectorcraft_geom::Affine; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let mut square = Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + if let Some(AppearanceItem::Fill(f)) = square.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + } + let sibling = Node::path(NodeId(801), shapes::rectangle(Rect::new(10.0, 60.0, 20.0, 70.0)), Appearance::default_art()); + let mut inst = instance(900, "D", Affine::IDENTITY); + inst.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(red.clone()), ..Default::default() }]; + let mut d = doc_with(inst); + add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)])); + d.symbols[0].dynamic = true; + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() + && let Some(b) = n.geometric_bounds() + { + fills.push((b.x0.round(), b.y0.round(), n.appearance.fill_paint())); + } + }); + assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}"); + assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); +} + +/// An instance whose fill stains its symbol's art places the art in its stained colors, with the +/// art's effects applied after the stain: a Transform moves the stained square and an Invert +/// inverts the stained color. +#[test] +fn placing_symbols_keeps_the_instances_stain() { + use vectorcraft_geom::Affine; + let blue = Color::rgb8(30, 90, 220); + let mut inst = instance(900, "Mark", Affine::IDENTITY); + inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + inst.appearance.items.retain(|i| i.is_fill()); + if let AppearanceItem::Fill(f) = &mut inst.appearance.items[0] { + f.opacity = 0.5; + } + let square = + Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0)); + let stained = vectorcraft_doc::symbol_overrides::instance_art(&square, &inst).appearance.fill_paint().color().unwrap(); + assert_ne!(stained.to_hex(), blue.to_hex()); + let [r, g, b] = stained.to_rgb(); + let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b); + for (id, params, want) in [("distort.transform", json!({"moveH": 100.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] { + let mut art = square.clone(); + art.appearance.effects.push(fx(id, params)); + let mut d = doc_with(inst.clone()); + add_symbol(&mut d, "Mark", art); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(!is_instance(&out, 900), "{id}: the instance is placed"); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() { + fills.extend(n.appearance.fill_paint().color().map(|c| c.to_hex())); + } + }); + assert_eq!(fills, [want.to_hex()], "{id}"); + } +} + +/// Every layer written, as DXF writes them (template layers are never written). +const ALL: WrittenLayers = WrittenLayers { hidden: true, non_printing: true }; + +fn is_instance(d: &Document, id: u64) -> bool { + d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) +} + +/// The art placed for an export is counted as baked: the copies an effect makes count, so an +/// instance whose baked art passes what is left of the budget stays an instance, and the budget +/// holds the baked art of the instances placed, exactly. +#[test] +fn placing_symbols_counts_the_baked_art() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)))).unwrap(); + add_symbol(&mut d, "S", gradient_copies(800)); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let each = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + assert!(each > 2, "the instance's group, and the copies as objects of their own: {each}"); + assert_eq!(all.node(NodeId(901)).map(|n| n.count_with_masks() as u64), Some(each)); + let fits = bake(&d, Some((true, 2 * each, ALL))).unwrap(); + assert!(!is_instance(&fits, 900) && !is_instance(&fits, 901)); + let short = bake(&d, Some((true, 2 * each - 1, ALL))).unwrap(); + assert_eq!(usize::from(is_instance(&short, 900)) + usize::from(is_instance(&short, 901)), 1, "one instance stays as it is"); +} + +/// The baked art of an instance placed inside another's art is counted once: a budget of exactly +/// the outer instance's baked art places both. +#[test] +fn placing_symbols_counts_nested_art_once() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", gradient_copies(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::IDENTITY))])); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let total = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let exact = bake(&d, Some((true, total, ALL))).unwrap(); + assert!(!is_instance(&exact, 900) && !is_instance(&exact, 802), "both are placed within {total}"); + let short = bake(&d, Some((true, total - 1, ALL))).unwrap(); + assert!(is_instance(&short, 900), "the outer instance stays as it is"); +} + +/// While an instance's art bakes, the art's own objects are held in the budget: an instance +/// nested in it whose art does not fit in what is left stays as it is, and the outer instance is +/// placed around it. +#[test] +fn placing_symbols_holds_the_outer_art_while_it_bakes() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", Node::group(NodeId(803), vec![Arc::new(gradient_copies(800))])); + let mut art: Vec> = (0..5) + .map(|i| Arc::new(Node::path(NodeId(810 + i), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()))) + .collect(); + art.push(Arc::new(instance(802, "Inner", Affine::IDENTITY))); + let outer = Node::group(NodeId(801), art); + // The outer instance's group around its art, with the inner instance left as it is. + let around = 1 + outer.count_with_masks() as u64; + add_symbol(&mut d, "Outer", outer); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let inner = all.node(NodeId(802)).map_or(0, |n| n.count_with_masks() as u64); + assert!(inner > 2 && inner <= around, "the inner art alone fits: {inner} <= {around}"); + let out = bake(&d, Some((true, around, ALL))).unwrap(); + assert!(!is_instance(&out, 900) && is_instance(&out, 802), "the outer instance is placed around the inner one"); +} + +/// Art baked for an instance and then left out, because it passes the budget, still counts: an +/// instance after it whose art would fit in the budget alone stays as it is, and a budget with +/// room for both places both. +#[test] +fn placing_symbols_counts_the_art_it_drops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(901, "Small", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(900, "Big", Affine::IDENTITY)).unwrap(); + add_symbol(&mut d, "Big", gradient_copies(800)); + add_symbol(&mut d, "Small", moved_square(810)); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let count = |id: u64| all.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (big, small) = (count(900), count(901)); + assert!(small < big, "{small} < {big}"); + let short = bake(&d, Some((true, big - 1, ALL))).unwrap(); + assert!(is_instance(&short, 900), "the big art passes {}", big - 1); + assert!(is_instance(&short, 901), "the big art's {big} objects count, so the small art does not fit"); + let both = bake(&d, Some((true, big + small, ALL))).unwrap(); + assert!(!is_instance(&both, 900) && !is_instance(&both, 901)); +} + +/// Instances the export does not write are not placed and do not count against the budget: with +/// room for one instance's baked art, hidden instances, an instance in a hidden group, one on a +/// template layer, one on a layer that does not print and one on a hidden layer (when the export +/// leaves those layers out) come first and stay as they are, and the visible instance after them +/// is placed. An export that writes those layers places their instances first. +#[test] +fn placing_symbols_skips_instances_the_export_leaves_out() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + add_symbol(&mut d, "S", gradient_copies(800)); + let each = bake(&d, Some((true, u64::MAX, ALL))).unwrap().node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let layer = d.layers[0].id; + let mut hidden = instance(901, "S", Affine::IDENTITY); + hidden.visible = false; + let mut group = Node::group(NodeId(902), vec![Arc::new(instance(903, "S", Affine::IDENTITY))]); + group.visible = false; + for (i, n) in [hidden, group].into_iter().enumerate() { + d.insert(Some(layer), i, n).unwrap(); + } + let mut left_out = vec![]; + for (id, template, printable, visible) in [(910, true, true, true), (920, false, false, true), (930, false, true, false)] { + let mut l = Node::layer(NodeId(id), &format!("L{id}"), vectorcraft_doc::LayerColor::Preset(0)); + if let NodeKind::Layer { template: t, printable: p, .. } = &mut l.kind { + *t = template; + *p = printable; + } + l.visible = visible; + if let Some(ch) = l.children_mut() { + ch.push(Arc::new(instance(id + 1, "S", Affine::IDENTITY))); + } + left_out.push(Arc::new(l)); + } + d.layers.splice(0..0, left_out); + let out = bake(&d, Some((true, each, WrittenLayers::default()))).unwrap(); + assert!(!is_instance(&out, 900), "the visible instance is placed within {each}"); + for id in [901, 903, 911, 921, 931] { + assert!(is_instance(&out, id), "{id} stays as it is"); + } + // An export that writes the layers that do not print, or the hidden layers, places that + // layer's instance first; the template layer's instance is never placed. + for (layers, first) in [(WrittenLayers { non_printing: true, hidden: false }, 921), (WrittenLayers { non_printing: false, hidden: true }, 931)] { + let out = bake(&d, Some((true, each, layers))).unwrap(); + assert!(!is_instance(&out, first) && is_instance(&out, 900) && is_instance(&out, 911), "{layers:?}"); + } +} + +/// `leaf` in `levels` groups, with ids from `id` up. +fn in_groups(id: u64, levels: u64, leaf: Node) -> Node { + (0..levels).fold(leaf, |n, i| Node::group(NodeId(id + i), vec![Arc::new(n)])) +} + +/// Placed art is drawn no deeper than the document may draw: an instance whose art's effects add +/// a level past it stays an instance, and the instances around it are placed. +#[test] +fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { + use vectorcraft_doc::depth::{DrawnHeights, MAX_DRAWN_DEPTH}; + use vectorcraft_geom::Affine; + // The instance on the layer is 2 deep; A to D each hold the next 31 objects down, and E's + // square is 2 deep in its art: 2 + 4 × 31 + 2 = MAX_DRAWN_DEPTH. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!d.too_deep(None)); + assert_eq!(DrawnHeights::of(&d).deepest(&d.layers[0], 1), MAX_DRAWN_DEPTH); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + // E's square becomes a group of copies, a level past the cap: E's instance stays. + assert!(is_instance(&out, 4100), "E's instance stays as it is"); + assert!(!is_instance(&out, 900) && !is_instance(&out, 3100), "A to D are placed"); + assert!(DrawnHeights::of(&out).deepest(&out.layers[0], 1) <= MAX_DRAWN_DEPTH); + // Without the chain above it, E's art is placed. + let mut shallow = doc_with(instance(900, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!is_instance(&bake_document_placing_symbols(&shallow, true, ALL).unwrap(), 900)); +} + +/// Art baked for an instance and then left out because it draws too deep still counts when the +/// baked art of a later instance is checked: that art fits before its effects add copies, but +/// not once they do. +#[test] +fn placing_symbols_counts_art_dropped_for_depth_against_later_baked_art() { + use vectorcraft_geom::Affine; + // The chain of placing_symbols_keeps_the_baked_art_within_the_drawn_depth, then F, whose + // square becomes a group of copies once baked. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + add_symbol(&mut d, "F", gradient_copies(6000)); + let layer = d.layers[0].id; + d.insert(Some(layer), 1, instance(901, "F", Affine::IDENTITY)).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + assert!(is_instance(&all, 4100) && !is_instance(&all, 900) && !is_instance(&all, 901)); + let count = |d: &Document, id: u64| d.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (a, f) = (count(&all, 900), count(&all, 901)); + // E's baked art, as placed where nothing above it draws deep. + let mut shallow = doc_with(instance(4100, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + let e = count(&bake(&shallow, Some((true, u64::MAX, ALL))).unwrap(), 4100); + assert!(f > 2, "F's copies are objects of their own: {f}"); + let short = bake(&d, Some((true, a + e + f - 1, ALL))).unwrap(); + assert!(!is_instance(&short, 900) && is_instance(&short, 4100), "A to D are placed, E is not"); + assert!(is_instance(&short, 901), "E's dropped {e} objects count, so F's baked {f} do not fit"); + let room = bake(&d, Some((true, a + e + f, ALL))).unwrap(); + assert!(!is_instance(&room, 900) && !is_instance(&room, 901)); +} diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index a3e31f1842..60cc3f2845 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -7,9 +7,124 @@ use vectorcraft_geom::{BezPath, FillRule, PathData}; /// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their /// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of -/// its members. `None` when there is nothing to clip by (the clipped art is then hidden). +/// its members. The live geometry effects of the clipping path (and of the members it clips by) +/// reshape the region, as they reshape what it paints, and so do the geometry effects of a path's +/// fills and strokes: the region is then the union of the paths they give, as an export bakes them. +/// `None` when there is nothing to clip by (the clipped art is then hidden). pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { - clip.clip_outline(Some(&outline_text), &unite) + match clip_effects_applied(clip, true) { + Some(m) => m.clip_outline(Some(&outline_text), &unite), + None => clip.clip_outline(Some(&outline_text), &unite), + } +} + +/// `n`, the clipping path of a clip group, with the live geometry effects of everything it clips by +/// applied, innermost first, and removed: a path or compound path with effects becomes a path of +/// its reshaped geometry that keeps its fills, strokes and other effects (its gradients follow the +/// effects, as an export bakes it), the paths of a compound path and the visible members of a group +/// are reshaped by their own effects before the effects around them, a group's or layer's own +/// effects reshape its members as an export evaluates them, and other objects with effects (type) +/// become a path of their reshaped region. A path or compound path whose fills or strokes have +/// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects +/// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their +/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the +/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a +/// geometry effect. Effects on the clip group or around it then reshape the result, after its own. +pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none()) +} + +/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of +/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of +/// `n`'s own fills and strokes are left out. +fn clip_effects_applied(n: &Node, items: bool) -> Option { + let mut m: Option = None; + let members: &[std::sync::Arc] = match &n.kind { + NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children, + _ => &[], + }; + // A group or layer clips by its visible members after its shaper; a compound path by all of + // its paths, and a nested clip group by its clipping path, shown or hidden + // ([`Node::clip_shapes`]). + let all = matches!(n.kind, NodeKind::Compound { .. } | NodeKind::Group { clip: true, .. }); + let skip = usize::from(matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) && n.extras().shaper.is_some()); + // The paths of a compound path draw with the compound path's fills and strokes, and stay paths. + let compound = matches!(n.kind, NodeKind::Compound { .. }); + for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) { + if let Some(r) = clip_effects_applied(c, items && !compound) + && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) + { + *slot = std::sync::Arc::new(r); + } + } + let items = items + && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i))); + if items && let Some((g, rule, ctx, mut p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { + let children = n + .appearance + .items + .iter() + .zip(&p.appearance.items) + .map(|(item, carried)| { + let (ig, mut it) = crate::bake::apply_item(item, carried, &g, &ctx); + crate::bake::clear_item_effects(&mut it); + let mut piece = Node::path(n.id, ig, Appearance { items: vec![it], ..Default::default() }); + if let NodeKind::Path { rule: r, .. } = &mut piece.kind { + *r = rule; + } + std::sync::Arc::new(piece) + }) + .collect(); + // The path's transparency and raster effects stay on the group of its pieces. + p.appearance.items.clear(); + p.kind = NodeKind::Group { children, clip: false }; + return Some(p); + } + if !crate::has_geometry(&n.appearance.effects) { + return m; + } + let cur = m.as_ref().unwrap_or(n); + // A group's or layer's effects reshape each of its members (a clip group's its clipping + // path), as an export evaluates them; otherwise they reshape its region, below. + if matches!(cur.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) + && let Some(e) = crate::group::evaluate_container(None, cur) + { + return Some(e); + } + // A path or compound path, as an export bakes it. + if let Some((g, rule, _, mut p)) = crate::bake::leaf_base(cur) { + let clipping = match &cur.kind { + NodeKind::Path { clipping, .. } => *clipping, + NodeKind::Compound { children, .. } => children.iter().any(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })), + _ => false, + }; + p.kind = Node::path(n.id, g, Appearance::default()).kind; + if let NodeKind::Path { rule: r, clipping: c, .. } = &mut p.kind { + (*r, *c) = (rule, clipping); + } + return Some(p); + } + // Type, as its painted outlines that its effects reshape as an export evaluates them. + if matches!(cur.kind, NodeKind::Text(_)) + && let Some(mut o) = crate::outline_text(cur) + { + o.visible = n.visible; + o.appearance.effects = n.appearance.effects.clone(); + if let Some(e) = crate::group::evaluate_container(None, &o) { + return Some(e); + } + } + let (bp, rule) = cur.clip_outline(Some(&outline_text), &unite)?; + let (g, _) = crate::bake::apply(&n.appearance.effects, &PathData::from_bezpath(&bp), &crate::GeomContext::of(n)); + let mut p = Node::path(n.id, g, Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + p.visible = n.visible; + Some(p) } /// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index bb2f605a91..20b4f982a3 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -661,8 +661,9 @@ pub fn has_container_appearance(n: &Node) -> bool { /// The regions a group's or layer's own fills and strokes paint, in document space, each with its /// fill rule: every visible member path and compound path, type as its glyph outlines and live /// objects evaluated; a compound shape as its area, with the symbol instances among its members -/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its -/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the +/// as their symbols' art from `doc`; a clip group or clipping layer (`n` itself or one among its +/// members) as the region its clipping path clips to ([`crate::clip::clip_outline`]), so members +/// it hides add nothing. Images, other symbol instances and guides add nothing. A Repeat counts the /// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> { fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { @@ -690,8 +691,14 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul } } fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { - let clip = usize::from(n.clips()); - for c in n.children().into_iter().flatten().skip(clip) { + // A clip group shows its members only inside its clipping path: its outline is that + // region, not the clipped members' own shapes, which may reach far outside it (#7289). + if n.clips() { + let clip = n.children().and_then(|ch| ch.first()); + out.extend(clip.and_then(|c| crate::clip::clip_outline(c)).map(|(bp, rule)| (PathData::from_bezpath(&bp), rule))); + return; + } + for c in n.children().into_iter().flatten() { push(doc, c, out); } } @@ -734,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 @@ -790,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); @@ -838,9 +903,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { /// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every /// depth of its groups and layers, by the art it draws ([`evaluate_container`], /// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members -/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the -/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with -/// everything in it. Calls nested in one another, through the +/// draw. A clip group's clipping path only takes the geometry effects in it into its geometry +/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects +/// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D +/// effect with everything in it. Calls nested in one another, through the /// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members /// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at /// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are. @@ -848,6 +914,13 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { let Some(_frame) = DrawnFrame::enter() else { return }; let clip = usize::from(n.clips()); let Some(ch) = n.children_mut() else { return }; + // The clipping path's own geometry effects shape it before the effects around it do. + if clip == 1 + && let Some(first) = ch.first_mut() + && let Some(applied) = crate::clip::with_clip_effects_applied(first) + { + *first = Arc::new(applied); + } for c in ch.iter_mut().skip(clip).filter(|c| c.visible) { if !DrawnFrame::take_member() { return; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index c6e2a78c73..877528d0bb 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -747,9 +747,9 @@ fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() { assert!((w - 250.0).abs() < 1e-6, "{w}"); } -/// A clip group's clipping path keeps its own effects; the clipped members take theirs first. +/// A clip group's clipping path and the clipped members take their own effects first. #[test] -fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { +fn a_clipping_path_takes_its_own_effects_before_a_groups_effects() { let mut clip = with_effect(square_at_100(), offset_10()); clip.id = NodeId(2); if let NodeKind::Path { clipping, .. } = &mut clip.kind { @@ -762,7 +762,9 @@ fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { let clip_group = art.children().unwrap()[0].clone(); let ch = clip_group.children().unwrap(); assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind); - assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path"); + assert!(crate::reshape::geometry_effects(&ch[0]).is_empty(), "the clipping path's Offset Path is applied"); + let w = ch[0].geometric_bounds().unwrap().width(); + assert!((w - 60.0).abs() < 0.5, "grown by 10 on each side, then scaled to 50 %: {w}"); assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art"); } @@ -1757,3 +1759,123 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}"); } } + +/// 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] +fn a_groups_own_fill_follows_a_clip_groups_clipping_path_not_its_hidden_art() { + let mut clip = square_at_100(); + clip.id = NodeId(2); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + // The clipped art reaches 100 pt past the clipping path on either side. + let hidden = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 100.0, 300.0, 200.0)), black()); + let clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(hidden)], clip: true }); + let mut g = Node::group(NodeId(10), vec![Arc::new(clipped.clone())]); + let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + fill.effects.push(offset_10()); + g.appearance.items = vec![AppearanceItem::Fill(fill)]; + g.appearance.set_contents_at(1); + let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y1 - b.y1).abs() < 0.5; + let expected = Rect::new(90.0, 90.0, 210.0, 210.0); + + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert_eq!(ch.len(), 2, "the fill's art below the clip group"); + assert_eq!(ch[0].id, NodeId(10)); + let b = drawn_bounds(&ch[0]).unwrap(); + assert!(near(b, expected), "the offset fill hugs the clipping path: {b:?}"); + + // The clip group itself with that fill: the same outline. + let mut own = clipped; + own.appearance = g.appearance.clone(); + assert_eq!(member_shapes(None, &own).len(), 1); + let b = member_shapes(None, &own)[0].0.bounds().unwrap(); + assert!(near(b, Rect::new(100.0, 100.0, 200.0, 200.0)), "the clipping path's region: {b:?}"); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c18a31d842..3d74ac4fe0 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -68,7 +68,10 @@ pub use adjust::{ ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment, is_channel, }; -pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; +pub use bake::{ + StrokeArt, WrittenLayers, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, + fresh_ids, needs_bake, +}; pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 8592776a95..257576822a 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -136,8 +136,86 @@ pub(crate) fn facet_art( composed_art(id, path, paint, Ramp::Rgb(light), seam) } -/// Composite smooth solid-fill facets with one clip and three channel groups per surface. -/// Black channel backdrops keep shared edges opaque before the final coverage is applied. +/// The width of the seam stroke of a facet's underlay: a round stroke that reaches 1 pt past each +/// edge, over the pixels that the antialiased edges of two neighboring facets each cover only in +/// part at 1:1. A clip to the surface's outline removes what it paints past the surface. +pub(crate) const UNDERLAY_SEAM: f64 = 2.0; + +/// The area facet `path` and its round seam stroke `seam` wide paint together: the stroke's outer +/// contours and the facet, all wound one way with a positive area. The stroke's inner contours, +/// wound the other way, are left out: the facet covers them, and contours that cancel each other +/// leave partial coverage where a union of footprints meets at a pixel. +pub(crate) fn footprint(path: &vectorcraft_geom::BezPath, seam: f64) -> vectorcraft_geom::BezPath { + use kurbo::{PathEl, Shape}; + let stroke = kurbo::stroke(path.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); + let sign = if stroke.area() < 0.0 { -1.0 } else { 1.0 }; + let mut footprint = vectorcraft_geom::BezPath::new(); + let mut contour = vectorcraft_geom::BezPath::new(); + for el in stroke.elements().iter().chain(std::iter::once(&PathEl::MoveTo(kurbo::Point::ZERO))) { + if matches!(el, PathEl::MoveTo(_)) && !contour.elements().is_empty() { + if contour.area() * sign >= 0.0 { + footprint.extend(contour.iter()); + } + contour = vectorcraft_geom::BezPath::new(); + } + contour.push(*el); + } + let mut boundary = path.clone(); + if boundary.area() * sign < 0.0 { + boundary = boundary.reverse_subpaths(); + } + footprint.extend(boundary.iter()); + if sign < 0.0 { footprint.reverse_subpaths() } else { footprint } +} + +/// Colored-light facets `faces` (in drawing order) drawn over an underlay of themselves, the same +/// facets grown to seams [`UNDERLAY_SEAM`] wide, all clipped once to the union of the facets' +/// footprints. Where two facets' antialiased edges meet, the underlay fills what they leave +/// uncovered with the neighbors' own colors, so the background does not show through. `None` unless +/// every face is a facet a surface draws: a path filled and stroked by its seam, or a facet composed +/// in a clip of its footprint, neither with an opacity mask. +pub(crate) fn sealed(id: vectorcraft_doc::NodeId, faces: &[std::sync::Arc]) -> Option { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeKind}; + let mut outline = vectorcraft_geom::BezPath::new(); + let mut underlay = Vec::with_capacity(faces.len()); + for face in faces { + if face.extras().mask.is_some() { + return None; + } + match &face.kind { + NodeKind::Path { clipping: false, .. } => { + let path = face.path_data()?.to_bezpath(); + let mut seam = None; + let mut grown = (**face).clone(); + for item in &mut grown.appearance.items { + if let AppearanceItem::Stroke(s) = item { + seam = Some(seam.map_or(s.width, |w: f64| w.max(s.width))); + s.width = UNDERLAY_SEAM; + } + } + outline.extend(footprint(&path, seam?).iter()); + underlay.push(Arc::new(grown)); + } + NodeKind::Group { children, clip: true } if children.len() == 2 => { + let clip = children.first()?.path_data()?.to_bezpath(); + let wider = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&clip, UNDERLAY_SEAM)), Appearance::default()); + let colors = children.get(1)?.clone(); + outline.extend(clip.iter()); + underlay.push(Arc::new(Node::new(id, NodeKind::Group { children: vec![Arc::new(wider), colors], clip: true }))); + } + _ => return None, + } + } + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&outline), Appearance::default()); + underlay.extend(faces.iter().cloned()); + Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, underlay))], clip: true })) +} + +/// Composite smooth solid-fill facets with one clip and three channel groups per surface. In each +/// channel, an underlay of the facets with wide seams lies under the facets themselves, so a +/// shared edge is opaque in that channel's own light before the channels are screened together; +/// the black backdrop shows only outside the surface, which the clip removes. pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node { use kurbo::Shape; use std::sync::Arc; @@ -167,20 +245,36 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t if let Some(value) = color.get_mut(channel) { *value = 1.0; } + let paints: Vec = facets + .iter() + .map(|face| { + if let Some(ramp) = face.rgb_gradient { + smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) + } else { + Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) + } + }) + .collect(); + let draw = |seam: fn(&vectorcraft_three_d::Facet) -> f64| -> Vec> { + facets + .iter() + .zip(&paints) + .map(|(face, paint)| { + let edge = seam_stroke(paint.clone(), seam(face)); + Arc::new(Node::path( + id, + face.path(), + Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(edge)], + ..Default::default() + }, + )) + }) + .collect() + }; let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))]; - children.extend(facets.iter().map(|face| { - let paint = if let Some(ramp) = face.rgb_gradient { - smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) - } else { - Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) - }; - let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - Arc::new(Node::path( - id, - face.path(), - Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() }, - )) - })); + children.extend(draw(|_| UNDERLAY_SEAM)); + children.extend(draw(|face| (face.thickness() / 2.0).clamp(0.05, 0.75))); let mut group = Node::group(id, children); group.isolate = true; group.blend = BlendMode::Screen; @@ -228,24 +322,25 @@ pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option) { } fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node { - use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask}; use vectorcraft_geom::{Rect, shapes}; - let (base, mask) = opaque_paint(paint); - // Clip once to the union of this single-contour face and its round seam stroke. - // Align their winding so the nonzero fill adds coverage instead of cancelling it. - let mut boundary = path.to_bezpath(); - let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); - if boundary.area() * footprint.area() < 0.0 { - boundary = boundary.reverse_subpaths(); + // An unplaced gradient (and a freeform gradient without points) stays fitted to the face's + // bounds grown by 1 pt, not to the larger cover below. + let mut paint = paint.clone(); + if let Paint::Gradient(g) = &mut paint { + g.pin(path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0)); } - footprint.extend(boundary.iter()); - let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default()); + let (base, mask) = opaque_paint(&paint); + // Clip once to the union of this single-contour face and its round seam stroke. + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&path.to_bezpath(), seam)), Appearance::default()); // Opaque rectangles cover the complete seam stroke, without separately antialiasing - // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. - let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0); + // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. They + // reach at least half a point past the seam stroke grown for an underlay ([`sealed`]), so + // their own edges stay outside either clip. + let margin = seam.max(1.0) + UNDERLAY_SEAM / 2.0; + let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(margin, margin); let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0)); let mut illumination = match light { Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)), @@ -380,4 +475,30 @@ mod tests { assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]); assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch"); } + + /// Under colored light, a face's unplaced source gradient is fitted to the face's bounds grown by + /// 1 pt, whatever the size of the cover rectangle that draws it; a placed one keeps its placement. + #[test] + fn composed_faces_fit_unplaced_gradients_to_the_face_not_the_cover() { + use vectorcraft_doc::NodeKind; + use vectorcraft_geom::{Rect, shapes}; + use vectorcraft_three_d::{RgbGradient, ShadeGradient}; + let face = shapes::rectangle(Rect::new(10.0, 20.0, 50.0, 40.0)); + let ramp = |b| ShadeGradient { start: Point::new(10.0, 20.0), end: Point::new(50.0, 20.0), brightness: [b, 1.0] }; + let light = RgbGradient { channels: [ramp(0.2), ramp(0.5), ramp(0.8)] }; + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let unplaced = GradientPaint { gradient: Gradient::new(GradientKind::Linear, stops), geom: None, angle: 0.0, swatch: None, freeform: None }; + let placed = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(80.0, 0.0), aspect: 1.0, focal: None }; + let fitted = unplaced.resolve(Rect::new(9.0, 19.0, 51.0, 41.0)); + for (geom, expected) in [(None, fitted), (Some(placed), placed)] { + let paint = Paint::Gradient(Box::new(GradientPaint { geom, ..unplaced.clone() })); + for seam in [0.05, 0.75] { + let art = facet_art(vectorcraft_doc::NodeId(1), &face, &paint, light, seam); + let NodeKind::Group { children, clip: true } = &art.kind else { panic!("a clipped composite") }; + let base = &children[1].children().unwrap()[0]; + let Paint::Gradient(g) = base.appearance.items[0].paint() else { panic!("the source gradient") }; + assert_eq!(g.geom, Some(expected), "seam {seam}"); + } + } + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 83ff68431f..f0d2601d1e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -689,18 +689,28 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, } } +/// Type `t` in a clip group's clipping path as its outlines ([`outline_text`]) with its +/// visibility, or with geometry effects as a path of the region they give it, which is what +/// [`crate::clip_outline`] clips by. +pub(crate) fn clipping_text_outline(t: &Node) -> Option { + if let Some(p) = crate::clip::with_clip_effects_applied(t) { + return Some(p); + } + let o = outline_text(t)?; + Some(Node { visible: t.visible, ..o }) +} + /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes -/// its plain outlines ([`outline_text`]) with its visibility, which is what -/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting -/// their pieces name are looked up in `doc`. +/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and +/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`. pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); let art = match &c.kind { - NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + NodeKind::Text(_) if clipping => clipping_text_outline(c), NodeKind::Text(_) => as_art(doc, c, None), _ => None, }; @@ -858,7 +868,7 @@ fn as_art_opts( /// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and /// remaining (raster) effects. -fn dressed(mut art: Node, n: &Node) -> Node { +pub(crate) fn dressed(mut art: Node, n: &Node) -> Node { art.name = n.name.clone(); art.visible = n.visible; art.opacity = n.opacity; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 74b23340cd..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, }; @@ -370,7 +371,9 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes))) } -/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts +/// One object's surface of revolution about the axis of `b`, and each face it draws ([`Drawn`]), +/// back to front: by depth, and where two faces overlap, by their depths there +/// ([`crate::solids::surface_order`]). `copies` counts /// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face /// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the /// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): @@ -388,7 +391,7 @@ fn surface( blend: vectorcraft_doc::color::BlendMode, expand: bool, copies: &mut Copies, -) -> Option<(Vec, Node)> { +) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { @@ -397,9 +400,12 @@ fn surface( if copies.surfaces > MAX_PAINT_COPIES { return None; } - let depths = facets.iter().map(|f| f.depth).collect(); + let order = crate::solids::surface_order(facets.iter().map(|f| (f.contours.first().map_or(&[][..], Vec::as_slice), f.plane, f.depth))); + let facets = in_order(facets, &order); + let drawn = + facets.iter().map(|f| Drawn { depth: f.depth, points: f.contours.first().cloned().unwrap_or_default(), plane: f.plane }).collect(); let p = merged_params(&fx.id, &fx.params); - return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + return Some((drawn, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; @@ -407,6 +413,8 @@ fn surface( if copies.surfaces > MAX_PAINT_COPIES { return None; } + let order = crate::solids::surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth))); + let faces = in_order(faces, &order); let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)); let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored); // Graphics past the limits are left out, and the surface draws without them and their paints' @@ -437,21 +445,28 @@ fn surface( && n.has_default_transparency()) .then(|| vectorcraft_three_d::visible_faces(&faces).ok()) .flatten(); - let faces = visible.unwrap_or_else(|| { - faces - .into_iter() - .map(|f| vectorcraft_three_d::VisibleFace { - path: f.path(), - brightness: f.brightness, - depth: f.depth, - gradient: f.gradient, - illumination: f.illumination, - light_gradient: f.light_gradient, - rgb_gradient: f.rgb_gradient, - }) - .collect() - }); - let depths: Vec = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect(); + let (faces, keys): (Vec, Vec) = match visible { + Some(visible) => { + let keys = visible.iter().map(|f| Drawn { depth: f.depth, points: vec![], plane: None }).collect(); + (visible, keys) + } + None => { + let keys = faces.iter().map(|f| Drawn { depth: f.depth, points: f.points.clone(), plane: f.plane }).collect(); + let faces = faces + .into_iter() + .map(|f| vectorcraft_three_d::VisibleFace { + path: f.path(), + brightness: f.brightness, + depth: f.depth, + gradient: f.gradient, + illumination: f.illumination, + light_gradient: f.light_gradient, + rgb_gradient: f.rgb_gradient, + }) + .collect(); + (faces, keys) + } + }; let children = faces .into_iter() .map(|face| { @@ -517,7 +532,21 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some((depths, group)) + Some((keys, group)) +} + +/// A face [`surface`] draws: its depth and, for [`crate::solids::surface_order`], its polygon on the +/// drawing and its plane (none for a face trimmed to the part of it that shows). +struct Drawn { + depth: f64, + points: Vec, + plane: Option, +} + +/// `items` in `order`, a permutation of their indices. +fn in_order(items: Vec, order: &[usize]) -> Vec { + let mut slots: Vec> = items.into_iter().map(Some).collect(); + order.iter().filter_map(|&k| slots.get_mut(k).and_then(Option::take)).collect() } /// A surface of one object about the bounds of `path`, built with options derived from `fx`. @@ -635,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 @@ -654,7 +689,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node) -> Result, n: &Node, expand: bool) -> Option { } else { let count = surfaces.len(); let mut faces = vec![]; - for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() { + for (member, (drawn, s, scopes)) in surfaces.into_iter().enumerate() { let (opacity, blend) = (s.opacity, s.blend); if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face))); + faces.extend(drawn.into_iter().zip(children).map(|(drawn, face)| (drawn, member, opacity, blend, scopes, face))); } } - // A stable sort: faces at the same depth keep the members' order. - faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + // A stable sort: faces at the same depth keep the members' order. Overlapping faces, of one + // member or of two, then go in the order of their depths where they overlap. + faces.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth)); + let order = crate::solids::surface_order(faces.iter().map(|(d, ..)| (d.points.as_slice(), d.plane, d.depth))); + let faces = in_order(faces, &order); if opaque { vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))] } else { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ac9de02cad..1fae38c611 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -223,6 +223,10 @@ pub(crate) fn solid_paint_sources(n: &Node) -> (Vec>, Ve } fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result, String> { + #[cfg(test)] + if !is_plane(id) { + SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1)); + } let view = view_options(p); match id { EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view), @@ -292,7 +296,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let mut shapes = members(n, label)?; reshape_members(n, before, &mut shapes); let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; - shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?; + check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; return cast_shadow(id, p, &shapes).map(|_| ()); } @@ -309,12 +313,24 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: None => (geometry(&path, before, &ctx), solid_paints(n)), }; let b = path.bounds().ok_or_else(anchors)?; - facets(id, &path, rule, b, p)?; let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; + check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; cast_shadow(id, p, &shapes).map(|_| ()) } +/// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b` +/// build? Only the profiles no solid of [`check_solids`] is built from are built here: every +/// shape's for Rotate, and for Extrude & Bevel and Inflate the profile of a shape with no fill, +/// whose solids are its strokes' outlines or none. A filled shape's profile is its fill's solid, +/// which [`check_solids`] builds once, about the bounds the effect draws it about. +fn check_profiles(id: &str, p: &Value, shapes: &[Profile], b: Rect) -> Result<(), String> { + shapes + .iter() + .filter(|s| is_plane(id) || !s.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)))) + .try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())) +} + /// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes` /// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not /// counted. @@ -559,10 +575,8 @@ fn surface(n: &Node, facets: Vec, 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(_))) { @@ -592,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 { @@ -614,11 +635,26 @@ fn paint_ref(item: &AppearanceItem) -> &Paint { } } -/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored -/// lights give each facet derived paints (`lighting::solid_surface`, or `lighting::facet_art` for -/// each face). +/// Are `facets` drawn over an underlay of themselves ([`crate::lighting::sealed`], +/// [`crate::lighting::solid_surface`])? Under colored lights, when any facet takes a smooth light +/// ramp. +fn underlaid(facets: &[Facet], colored: bool) -> bool { + colored && facets.iter().any(|f| f.rgb_gradient.is_some()) +} + +/// The most paint copies `surface` makes for one facet ([`facet_copies`]). +const MAX_FACET_COPIES: usize = 12; + +/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), twice that when +/// the facets lie over an underlay of themselves, and [`MAX_FACET_COPIES`] when colored lights also +/// give each facet derived paints in three channels (`lighting::solid_surface`). fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { - facets.len().saturating_mul(if composites_facets(facets, paint, colored) { 6 } else { 2 }) + let per_facet = match (underlaid(facets, colored), paint.color().is_some()) { + (true, true) => MAX_FACET_COPIES, + (true, false) => 4, + (false, _) => 2, + }; + facets.len().saturating_mul(per_facet) } /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the @@ -658,6 +694,9 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { thread_local! { /// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests. static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How many Extrude & Bevel (Classic included) and Inflate solids [`facets`] and + /// [`built_solids`] have built on this thread, for the tests. + static SOLID_GEOMETRY_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from @@ -687,7 +726,11 @@ fn built_solids<'a>( #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); let built = match inflated.as_mut() { - Some(each) => each.next().unwrap_or_else(|| Ok(vec![])), + Some(each) => { + #[cfg(test)] + SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1)); + each.next().unwrap_or_else(|| Ok(vec![])) + } None => facets(id, area, *rule, bounds, p), }; let result = match built { @@ -804,14 +847,14 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { /// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces /// and whose paint copies take `paint_bytes` so far: as many as keep the faces within -/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies -/// of the costliest stroke's paint. +/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as +/// [`MAX_FACET_COPIES`] copies of the costliest stroke's paint. fn cut_room<'a>(items: impl Iterator, faces: usize, paint_bytes: usize) -> usize { let per_face = items .filter(|item| matches!(item, AppearanceItem::Stroke(_))) .map(|item| { let mut copies = 0usize; - crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item)); + crate::revolve::paint_copies_add(&mut copies, MAX_FACET_COPIES, paint_ref(item)); copies }) .max() @@ -1261,6 +1304,35 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec { local_order(faces, order) } +/// The order, back to front, of the faces of one or more Revolve surfaces `faces`, given back to +/// front by depth: each face's polygon on the drawing, its plane and its depth. When two +/// overlapping faces are drawn the wrong way round for their depths where they overlap, every pair +/// of overlapping faces with a convex polygon and a plane goes in the order of its depths there, +/// the face nearer the viewer after the other ([`local_order`]), and each step draws the face that +/// comes first in the given order of those whose faces before are drawn, so a face without a plane +/// or a convex polygon can also move. Otherwise the order given. +pub(crate) fn surface_order<'a>(faces: impl IntoIterator, f64)>) -> Vec { + let placed: Vec = faces + .into_iter() + .map(|(points, plane, depth)| { + let local = plane.is_some() && convex(points).is_some(); + // Every face counts as one of a stroke's on one shape, so each overlapping pair is compared. + Placed { + depth, + side: true, + away: false, + bounds: polygon_bounds(points), + member: 0, + paints: Paints::Other, + outline: if local { points.to_vec() } else { vec![] }, + plane: plane.filter(|_| local), + } + }) + .collect(); + let order = (0..placed.len()).collect(); + local_order(&placed, order) +} + /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { @@ -1636,6 +1708,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile let split = split && solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum::() <= MAX_FACE_COMPOSITES; let members: Vec = solids.iter().map(|s| s.4).collect(); + // Which surfaces lie over an underlay of their facets, in one order or whole + // ([`in_one_order`], [`underlaid_surface`]). + let seals: Vec = solids.iter().map(|(.., f, _)| underlaid(f, colored)).collect(); + let seal = |range: std::ops::Range| seals.get(range).is_some_and(|s| s.iter().any(|s| *s)); let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { @@ -1671,19 +1747,37 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile run.push(s); } let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none()); + let range = first..first + run.len(); if ordered(member) && flat { let scopes = run.first().map_or(&[][..], |s| s.2); - out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed))))); + let seals = seals.get(range).unwrap_or_default(); + out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed, seals))))); } else { - out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); + let run = run.into_iter().zip(range).map(|((_, s, scopes, _), k)| (scopes, Arc::new(underlaid_surface(n.id, s, seal(k..k + 1))))); + out.extend(with_graphics(run.collect(), first, &arts)); } } return Some(masked(n.id, out)); } if !opaque || !surfaces.iter().all(plain) { + let surfaces = + surfaces.into_iter().enumerate().map(|(k, (d, s, scopes, e))| (d, underlaid_surface(n.id, s, seal(k..k + 1)), scopes, e)).collect(); return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); } - Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) + Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed, &seals)))]) +} + +/// A plain `surface` ([`surface`]: a group of its facets) over an underlay of its facets when +/// `seal`, keeping the surface's opacity, blend mode and opacity mask. +fn underlaid_surface(id: NodeId, surface: Node, seal: bool) -> Node { + let NodeKind::Group { children, clip: false } = &surface.kind else { return surface }; + let Some(mut out) = seal.then(|| crate::lighting::sealed(id, children)).flatten() else { return surface }; + out.opacity = surface.opacity; + out.blend = surface.blend; + if let Some(mask) = surface.extras().mask.clone() { + out.extras_mut().mask = Some(mask); + } + out } /// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in @@ -1691,7 +1785,16 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile /// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops /// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of /// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. -fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { +/// The faces of the surfaces that `seals` marks lie over underlays of themselves +/// ([`underlaid_runs`], [`underlaid_surface`]). +fn in_one_order( + id: NodeId, + surfaces: Vec>, + first: usize, + arts: &[(usize, PlanarFace, Arc)], + sealed: bool, + seals: &[bool], +) -> Vec> { let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); @@ -1730,7 +1833,46 @@ fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: (masks.len(), art) })); } - if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() } + let takes_underlay = |surface: usize| seals.get(surface) == Some(&true); + if masks.iter().any(Option::is_some) { + // The faces of a surface without a mask are sealed run by run before the masked surfaces' + // faces go in runs of their own, each holding one surface's faces, sealed with its mask. + let unmasked = underlaid_runs(id, drawn, |s| takes_underlay(s) && masks.get(s).is_some_and(Option::is_none)); + return in_runs(id, unmasked, &masks) + .into_iter() + .map(|run| match &run.kind { + NodeKind::Group { clip: false, .. } if seals.iter().any(|s| *s) && run.extras().mask.is_some() => { + Arc::new(underlaid_surface(id, (*run).clone(), true)) + } + _ => run, + }) + .collect(); + } + underlaid_runs(id, drawn, takes_underlay).into_iter().map(|(_, face)| face).collect() +} + +/// `faces` in their drawing order, each with the index of its surface. Each run of consecutive +/// faces of one surface that `seal` takes lies over an underlay of its own faces, clipped to their +/// outline ([`crate::lighting::sealed`]), so no underlay reaches under another surface's faces or a +/// graphic drawn between them. A sealed run keeps its surface's index. +fn underlaid_runs(id: NodeId, faces: Vec<(usize, Arc)>, seal: impl Fn(usize) -> bool) -> Vec<(usize, Arc)> { + let mut out = Vec::with_capacity(faces.len()); + let mut run: Vec> = vec![]; + let mut faces = faces.into_iter().peekable(); + while let Some((surface, face)) = faces.next() { + run.push(face); + if faces.peek().is_some_and(|(next, _)| *next == surface) { + continue; + } + match seal(surface).then(|| crate::lighting::sealed(id, &run)).flatten() { + Some(sealed) => { + out.push((surface, Arc::new(sealed))); + run.clear(); + } + None => out.extend(run.drain(..).map(|face| (surface, face))), + } + } + out } /// `faces` in their back-to-front order, each with the index of its surface, where some surfaces @@ -2178,3 +2320,6 @@ mod tests_sources; #[cfg(test)] mod tests_shadow; + +#[cfg(test)] +mod tests_revolve_order; 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/tests.rs b/crates/effects/src/solids/tests.rs index eb9e81bbc0..ab163e45cd 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -535,14 +535,53 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { assert_eq!(drawn(&solid, Some(colored)).0, 1); } +/// The faces `run` stands for: a run of faces over an underlay of themselves ([`crate::lighting::sealed`]: +/// a clip and a group of the underlay's copies followed by the faces) gives its faces, any other +/// node gives itself. +fn unsealed(run: &Arc) -> Vec> { + if let NodeKind::Group { clip: true, children } = &run.kind + && let [clip, inner] = children.as_slice() + && matches!(clip.kind, NodeKind::Path { .. }) + && let NodeKind::Group { clip: false, children: kids } = &inner.kind + && !kids.is_empty() + && kids.len() % 2 == 0 + { + let (under, faces) = kids.split_at(kids.len() / 2); + let underlay = |k: &Arc| match &k.kind { + NodeKind::Group { clip, .. } => *clip, + _ => k.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.width == crate::lighting::UNDERLAY_SEAM)), + }; + if under.first().is_some_and(underlay) { + return faces.to_vec(); + } + } + vec![run.clone()] +} + +/// The bounds of every face under `n`, a sealed run's faces one by one ([`unsealed`]). +fn face_bounds_under(n: &Arc, out: &mut Vec) { + let faces = unsealed(n); + if faces.len() != 1 || !Arc::ptr_eq(&faces[0], n) { + out.extend(faces.iter().map(|f| drawn_face_bounds(f))); + return; + } + if let NodeKind::Group { clip: true, .. } = &n.kind { + out.push(drawn_face_bounds(n)); + } + for child in n.children().into_iter().flatten() { + face_bounds_under(child, out); + } +} + /// The bounds of face `f` of 3D art: its path's, or for a clipped colored composite -/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 1 pt. +/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 2 pt (a +/// point past the seam and half the underlay's seam). fn drawn_face_bounds(f: &Node) -> Rect { match &f.kind { NodeKind::Path { .. } => f.path_data().and_then(PathData::bounds).unwrap(), NodeKind::Group { clip: true, children } => { let cover = &children[1].children().unwrap()[0]; - cover.path_data().and_then(PathData::bounds).unwrap().inflate(-1.0, -1.0) + cover.path_data().and_then(PathData::bounds).unwrap().inflate(-2.0, -2.0) } _ => panic!("a face is a path or a clipped composite"), } @@ -587,7 +626,7 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { let art = solid_art(&with).unwrap(); let surfaces = art.children().unwrap(); assert_eq!(surfaces.len(), 1, "{what}: one order"); - let faces = surfaces[0].children().unwrap(); + let faces: Vec> = surfaces[0].children().unwrap().iter().flat_map(unsealed).collect(); assert_eq!(faces.len(), parts.len(), "{what}: one face per facet"); for (k, (face, (facet, ..))) in faces.iter().zip(&parts).enumerate() { let (a, b) = (drawn_face_bounds(face), facet.path().bounds().unwrap()); @@ -616,11 +655,9 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { g.appearance.effects.push(fx(id, params.clone())); let bounds = |art: &Node| { let mut out = vec![]; - art.walk(&mut |n| { - if let NodeKind::Group { clip: true, .. } = &n.kind { - out.push(drawn_face_bounds(n)); - } - }); + for child in art.children().into_iter().flatten() { + face_bounds_under(child, &mut out); + } out }; let (plain, scoped) = (solid_art(&plain).unwrap(), solid_art(&g).unwrap()); @@ -637,6 +674,89 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { assert!(whole_misdrawn > 0, "whole surfaces show farther faces"); } +/// Under a colored light, the faces of an Inflate in gradients, in one order, lie over underlays +/// run by run: each run of consecutive faces of one surface (the fill's or the dashed stroke's) +/// over an underlay of its own faces, so no underlay reaches under the other surface's faces. +#[test] +fn a_colored_inflate_in_gradients_lies_over_underlays_of_each_surfaces_runs() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |c: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)], + )))) + }; + let mut n = stroked(&letter_c()); + for item in &mut n.appearance.items { + match item { + AppearanceItem::Fill(f) => f.paint = gradient(RED), + AppearanceItem::Stroke(s) => { + s.paint = gradient(BLUE); + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + } + } + let p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}); + let cut = drawn_parts(&n, INFLATE, &p).len(); + let mut lit = p.clone(); + lit["lights"] = json!([{"color": "#ff8080"}]); + n.appearance.effects.push(fx(INFLATE, lit)); + // Which surface a face belongs to, by the first stop of its paint (a composed face's own paint, + // or a path face's shaded one): red for the fill's, blue for the stroke's. + fn surface_of(face: &Node) -> Option { + let item = match &face.kind { + NodeKind::Path { .. } => face.appearance.items.first()?, + _ => face.children()?.get(1)?.children()?.first()?.appearance.items.first()?, + }; + let Paint::Gradient(g) = item.paint() else { return None }; + let [r, _, b] = g.gradient.stops.first()?.color.to_rgb(); + Some(if r > b { RED } else { BLUE }) + } + // The faces in `order`, and the surface of each run over an underlay, looking into the groups + // that carry a surface's opacity mask. + fn runs(order: &[Arc], faces: &mut usize, surfaces: &mut Vec) { + for child in order { + let NodeKind::Group { children, clip } = &child.kind else { + assert!(surface_of(child).is_some(), "a face"); + *faces += 1; + continue; + }; + if !clip { + assert!(child.extras().mask.is_some(), "a masked run"); + runs(children, faces, surfaces); + continue; + } + // A composed face isolates its colors; a run's faces are not isolated together. + if children[1].isolate { + assert!(surface_of(child).is_some(), "a composed face"); + *faces += 1; + continue; + } + let stack = children[1].children().unwrap(); + let (under, own) = stack.split_at(stack.len() / 2); + assert_eq!(under.len(), own.len(), "one underlay face for each face"); + let colors: Vec<_> = own.iter().map(|f| surface_of(f).unwrap()).collect(); + assert!(colors.iter().all(|c| *c == colors[0]), "a run holds one surface's faces"); + surfaces.push(colors[0]); + *faces += own.len(); + } + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut, "{faces} faces, {cut} under a white light, whose cells are not triangulated"); + assert!(surfaces.contains(&RED) && surfaces.contains(&BLUE), "{surfaces:?}"); + // With transparent stops in the fill's gradient, the fill's faces go in masked runs; the + // stroke's faces, without a mask, still lie over underlays of their own runs. + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) + && let Paint::Gradient(g) = &mut f.paint + { + g.gradient.stops.iter_mut().for_each(|s| s.opacity = 0.5); + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut && surfaces.contains(&RED) && surfaces.contains(&BLUE), "{faces} faces, {surfaces:?}"); +} + /// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they /// overlap is cut along the line where their depths meet, into the piece in front of the wall and /// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke @@ -724,7 +844,7 @@ fn cutting_stays_within_the_face_limit_and_the_paint_allowance() { let items = stroked(&letter_c()).appearance.items.clone(); let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap(); let mut one = 0usize; - crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke)); + crate::revolve::paint_copies_add(&mut one, MAX_FACET_COPIES, paint_ref(stroke)); let max = crate::revolve::MAX_PAINT_COPIES; assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one)); assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0); @@ -953,6 +1073,48 @@ fn an_objects_solids_count_their_faces_together() { } } +/// How many Extrude & Bevel and Inflate solids `check` builds, and its result. +fn geometry_builds(check: impl FnOnce() -> Result<(), String>) -> (usize, Result<(), String>) { + SOLID_GEOMETRY_BUILT.with(|b| b.set(0)); + let result = check(); + (SOLID_GEOMETRY_BUILT.with(std::cell::Cell::get), result) +} + +/// The check builds each solid the effect draws once, about the bounds it draws it about: one for +/// a fill and one for each stroke, on an object, on a group's members and on an appearance item, +/// with the shadow on or off. +#[test] +fn the_check_builds_each_drawn_solid_once() { + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + let n = square_node(); + assert_eq!(geometry_builds(|| validate_solid(&n, id, &json!({}))), (2, Ok(())), "{id}: a fill and a stroke"); + let mut other = square_node(); + other.id = NodeId(2); + let g = Node::group(NodeId(9999), vec![Arc::new(square_node()), Arc::new(other)]); + assert_eq!(geometry_builds(|| validate_solid(&g, id, &json!({}))), (4, Ok(())), "{id}: two members' fills and strokes"); + } + for id in [EXTRUDE, INFLATE] { + let n = square_node(); + assert_eq!(geometry_builds(|| validate_three_d_at(&n, id, &json!({}), (Some(0), 0))), (1, Ok(())), "{id}: on the fill"); + let shadow = json!({"shadow": true}); + assert_eq!(geometry_builds(|| validate_solid(&n, id, &shadow)), (2, Ok(())), "{id}: with the shadow on"); + } +} + +/// A shape with no fill draws no solid of its profile, so the check builds the profile on its own +/// as well as the strokes' solids, and an unpainted shape's profile alone. +#[test] +fn the_check_builds_the_profile_of_a_shape_with_no_fill() { + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + let mut stroked = square_node(); + stroked.appearance.items.retain(|i| matches!(i, AppearanceItem::Stroke(_))); + assert_eq!(geometry_builds(|| validate_solid(&stroked, id, &json!({}))), (2, Ok(())), "{id}: a stroke alone"); + let mut bare = square_node(); + bare.appearance.items.clear(); + assert_eq!(geometry_builds(|| validate_solid(&bare, id, &json!({}))), (1, Ok(())), "{id}: no paint"); + } +} + /// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke. fn polygon_node(id: u64, corners: usize) -> Node { let points: Vec = (0..corners) @@ -1553,12 +1715,14 @@ fn a_colored_solid_face_on_its_own_keeps_the_composites_colors() { assert_eq!(one[0], paint, "the paint"); // The composite: its paint cover, then each channel's black backdrop and the face's ramp. let all_fills = fills(&batch); - let ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + let mut ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + // Each channel's ramp is drawn twice, in the underlay and in the facet itself. + ramps.dedup(); assert_eq!(all_fills.first(), Some(&paint)); assert_eq!(ramps, one.iter().skip(1).collect::>(), "the same light ramps"); // The single face's clip holds its seam: the clip reaches past the facet by half the seam. let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); - let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0).collect(); + let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0 && *w != crate::lighting::UNDERLAY_SEAM).collect(); assert!(widths.len() == 3 && widths.iter().all(|w| (w - seam).abs() < 1e-12), "{widths:?}"); let NodeKind::Group { children, .. } = &single.kind else { panic!("a clip group") }; let (clip, facet) = (children[0].path_data().and_then(PathData::bounds).unwrap(), face.path().bounds().unwrap()); diff --git a/crates/effects/src/solids/tests_revolve_order.rs b/crates/effects/src/solids/tests_revolve_order.rs new file mode 100644 index 0000000000..96650cf2f8 --- /dev/null +++ b/crates/effects/src/solids/tests_revolve_order.rs @@ -0,0 +1,183 @@ +use serde_json::json; + +use super::*; +use crate::revolve::{CLASSIC_REVOLVE, REVOLVE, revolve_art, revolve_options}; + +/// The first pair of faces `(polygon, plane)`, in the order given, drawn the wrong way round where +/// their polygons overlap: the earlier one nearer the viewer at the middle of the overlap. +fn misordered(faces: &[(Vec, Option)]) -> Option<(usize, usize)> { + for (i, (a, pa)) in faces.iter().enumerate() { + let Some(pa) = pa.filter(|_| convex(a).is_some()) else { continue }; + for (j, (b, pb)) in faces.iter().enumerate().skip(i + 1) { + let Some(pb) = pb else { continue }; + let Some(turn) = convex(b) else { continue }; + if !rects_overlap(polygon_bounds(a), polygon_bounds(b)) { + continue; + } + let Some(mid) = centroid(&clip_convex(a, b, turn)) else { continue }; + let (Some(da), Some(db)) = (pa.depth_at(mid), pb.depth_at(mid)) else { continue }; + if da - db > 1e-6 * (1.0 + da.abs().max(db.abs())) { + return Some((i, j)); + } + } + } + None +} + +/// An open, vase-like profile of straight edges. +fn vase() -> PathData { + let mut b = BezPath::new(); + b.move_to((30.0, 20.0)); + for p in [(90.0, 20.0), (55.0, 65.0), (95.0, 110.0), (30.0, 145.0)] { + b.line_to(p); + } + PathData::from_bezpath(&b) +} + +/// The `k`th of five closed 14 × 20 pt rectangles stepping down and away from the axis, as the +/// dashes of a dashed line's outline do. +fn dash(k: usize) -> PathData { + let (x, y) = (30.0 + 12.0 * k as f64, 20.0 + 26.0 * k as f64); + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + 14.0, y)); + b.line_to((x + 14.0, y + 20.0)); + b.line_to((x, y + 20.0)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// The five [`dash`]es as one compound path. +fn dashes() -> PathData { + PathData::new((0..5).flat_map(|k| dash(k).subpaths).collect()) +} + +/// A tilted three-quarter turn of [`vase`], whose faces sorted by their middles' depths draw a +/// nearer face under a farther one where the two overlap. +fn params() -> Value { + json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 24, "cap": true}) +} + +/// A three-quarter turn of [`dashes`], steeply tilted and in wide perspective, whose classic facets +/// sorted by their middles' depths draw a nearer facet under a farther one where the two overlap. +fn classic_params() -> Value { + json!({"angle": 270, "offset": 12, "rotationX": -60, "rotationY": 0, "perspectiveAngle": 80, "cap": true}) +} + +fn node(path: PathData) -> Node { + Node::path(NodeId(1), path, Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.8)), Paint::None, 0.0)) +} + +/// The faces of `faces` (polygon, plane and outline) in the order `art` draws their outlines. +fn drawn_order(art: &Node, faces: &[(Vec, Option, PathData)]) -> Vec<(Vec, Option)> { + let mut out = vec![]; + art.walk(&mut |n| { + if let Some(path) = n.path_data() + && let Some((points, plane, _)) = faces.iter().find(|(.., outline)| outline == path) + { + out.push((points.clone(), *plane)); + } + }); + out +} + +/// Of a tilted, revolved vase, a face sorted by the depth of its middle goes under a farther face +/// that it is in front of where the two overlap. The surface draws the nearer face last there, on +/// its own and as a member of a revolved group. +#[test] +fn a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last() { + let path = vase(); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(¶ms())).unwrap(); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect(); + let mut n = node(path.clone()); + n.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms()).unwrap()); + let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None, "an object"); + let mut g = Node::group(NodeId(9), vec![Arc::new(node(path))]); + g.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms()).unwrap()); + let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None, "a group's member"); +} + +/// The faces of a group's members, sorted together by the depths of their middles, go in the order +/// of their depths where they overlap, those of two members as well as those of one. +#[test] +fn a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last() { + let p = json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 16, "cap": true}); + let members: Vec = (0..5).map(dash).collect(); + let b = dashes().bounds().unwrap(); + let mut faces: Vec<_> = members.iter().flat_map(|m| vectorcraft_three_d::revolve(m, b, revolve_options(&p)).unwrap()).collect(); + faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect(); + let mut g = Node::group(NodeId(9), members.into_iter().map(|m| Arc::new(node(m))).collect()); + g.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap()); + let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None); +} + +/// Revolve (Classic) draws the nearer of two overlapping facets last where they overlap, too. +#[test] +fn a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last() { + let path = dashes(); + // Revolve (Classic) sweeps its surface unshaded, with one facet for each cell. + let classic = vectorcraft_three_d::Revolve { + shade: false, + smooth_shading: false, + lights: None, + light_color: [1.0; 3], + ..revolve_options(&crate::merged_params(CLASSIC_REVOLVE, &classic_params())) + }; + let facets = vectorcraft_three_d::revolve_facets(&path, path.bounds().unwrap(), classic).unwrap(); + let by_depth: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the facets by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane, f.path())).collect(); + let mut n = node(path); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &classic_params()).unwrap()); + let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed); + assert_eq!(drawn.len(), facets.len()); + assert_eq!(misordered(&drawn), None); +} + +/// A surface whose faces by depth are already drawn the right way round wherever they overlap keeps +/// that order. +#[test] +fn a_surface_already_in_order_keeps_it() { + let path = vase(); + let p = json!({"angle": 360, "offset": 12, "rotationX": 0, "rotationY": 0, "segments": 24, "cap": true}); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&p)).unwrap(); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert_eq!(misordered(&by_depth), None); + let order = surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth))); + assert_eq!(order, (0..faces.len()).collect::>()); +} + +/// Of two overlapping faces, the one nearer the viewer where they overlap goes after the other, +/// whatever their depths; a face without a plane, and one that overlaps neither, keep their places. +/// Two faces at the same depth there keep their order. +#[test] +fn surface_order_puts_the_nearer_face_where_they_overlap_last() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + // z = 10 - x: at the middle of its overlap with the next square (x 7.5) it is at z 2.5. + let r = std::f64::consts::FRAC_1_SQRT_2; + let sloped = Some(DepthPlane { normal: [r, 0.0, r], offset: 10.0 * r, center: Point::ZERO, eye: None }); + let flat = |z: f64| Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }); + let (a, b, apart) = (square(0.0), square(5.0), square(40.0)); + let faces = [(&a[..], sloped, 5.0), (&apart[..], flat(0.0), 1.0), (&b[..], flat(3.0), 3.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "the flat face, nearer at the overlap, is already last"); + let faces = [(&b[..], flat(2.0), 2.0), (&apart[..], flat(0.0), 3.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "the sloped face, nearer at the overlap, is already last"); + // The sloped face's middle (x 5) is at z 5, but the flat face at z 3 is nearer at the overlap. + let faces = [(&b[..], flat(3.0), 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![1, 2, 0], "the flat face is nearer at the overlap"); + let faces = [(&b[..], None, 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "no plane"); + let faces = [(&b[..], flat(2.5), 3.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1], "the same depth there"); +} diff --git a/crates/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/effects/src/tests.rs b/crates/effects/src/tests.rs index 0023fc5b16..c43d383c19 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod clipfx; mod imageoutline; mod painted; mod rasterfollow; diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs new file mode 100644 index 0000000000..370f32fa81 --- /dev/null +++ b/crates/effects/src/tests/clipfx.rs @@ -0,0 +1,401 @@ +//! Live effects on the clipping path of a clip group: the region it clips to takes the shape its +//! geometry effects give it, on the canvas as in an export. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, Shape, shapes}; + +use crate::{bake_document, clip_outline}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// A 100 pt square clipping path at (50, 50) with `effects`. +fn clipping_path(id: u64, effects: Vec) -> Node { + let mut p = Node::path(NodeId(id), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut p.kind { + *clipping = true; + } + p.appearance.effects = effects; + p +} + +/// A clip group: `clip` clipping a red 200 pt square at the origin. +fn clip_group(id: u64, clip: Node) -> Node { + let art = Node::path( + NodeId(id + 100), + shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }) +} + +fn bounds(n: &Node) -> Rect { + clip_outline(n).map(|(bp, _)| bp.bounding_box()).unwrap() +} + +/// The clipping path of the first clip group in `doc` after an export bakes it. +fn baked_clip(doc: &Document) -> Arc { + fn first(n: &Arc) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(first), + } + } + let baked = bake_document(doc).unwrap(); + baked.layers.iter().find_map(first).unwrap() +} + +#[test] +fn transform_moves_the_clip() { + let clip = clipping_path(1, vec![fx("distort.transform", json!({"moveH": 60}))]); + let b = bounds(&clip); + assert!((b.x0 - 110.0).abs() < 1e-6 && (b.x1 - 210.0).abs() < 1e-6, "the clip moves with the clipping path: {b:?}"); + assert!((b.y0 - 50.0).abs() < 1e-6 && (b.y1 - 150.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn roughen_clips_as_an_export_does() { + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let group = clip_group(1, clipping_path(2, vec![fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))])); + d.insert(Some(layer), 0, group.clone()).unwrap(); + let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") }; + let live = clip_outline(&children[0]).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live.1, exported.1); + assert_eq!(live.0, exported.0, "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0), "the roughened outline, not the plain square"); +} + +#[test] +fn nested_members_keep_their_effects() { + // A group as the clipping path: its member's Transform effect moves its part of the region. + let moved = clipping_path(2, vec![fx("distort.transform", json!({"moveV": 60}))]); + let group = Node::group(NodeId(1), vec![Arc::new(moved)]); + let b = bounds(&group); + assert!((b.y0 - 110.0).abs() < 1e-6 && (b.y1 - 210.0).abs() < 1e-6, "{b:?}"); + // A clip group as the clipping path clips by its own clipping path, effects and all. + let nested = clip_group(3, clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))])); + let b = bounds(&nested); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}"); + // So does the nested clip group's own effect. + let mut nested = clip_group(7, clipping_path(8, vec![])); + nested.appearance.effects.push(fx("distort.transform", json!({"moveH": 30}))); + let b = bounds(&nested); + assert!((b.x0 - 80.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6, "{b:?}"); + // The group's own Transform effect applies on top of its members'. + let mut group = Node::group(NodeId(5), vec![Arc::new(clipping_path(6, vec![fx("distort.transform", json!({"moveV": 60}))]))]); + group.appearance.effects.push(fx("distort.transform", json!({"moveH": 10}))); + let b = bounds(&group); + assert!((b.x0 - 60.0).abs() < 1e-6 && (b.y0 - 110.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn a_roughened_group_clips_as_an_export_does() { + // Two members side by side, Roughen on the group: each member is roughened, as in the export. + let member = |id: u64, x: f64| { + Arc::new(Node::path( + NodeId(id), + shapes::rectangle(Rect::new(x, 50.0, x + 40.0, 150.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + }; + let mut clip = Node::group(NodeId(2), vec![member(3, 50.0), member(4, 110.0)]); + clip.appearance.effects.push(fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, clip.clone())).unwrap(); + let live = clip_outline(&clip).unwrap(); + assert_eq!(live.0, clip_outline(&baked_clip(&d)).unwrap().0, "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0)); +} + +#[test] +fn a_compound_paths_member_keeps_its_effect() { + // A ring whose hole a Transform effect moves 20 pt right, as the export bakes it. + let ring = |effects: Vec| { + let outer = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + let mut hole = Node::path(NodeId(4), shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)), Appearance::default()); + hole.appearance.effects = effects; + Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: vectorcraft_geom::FillRule::EvenOdd }) + }; + let moved = ring(vec![fx("distort.transform", json!({"moveH": 20}))]); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, moved.clone())).unwrap(); + let live = clip_outline(&moved).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves"); + // With a Transform of the compound path's own moving the whole ring 10 pt down after it. + let mut both = moved.clone(); + both.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, both.clone())).unwrap(); + let live = clip_outline(&both).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), clip_outline(&moved).unwrap().0.bounding_box(), "the ring moves down"); +} + +#[test] +fn the_clipping_paths_effects_apply_before_the_clip_groups() { + // Scale 50 % on the clipping path, then Offset Path 10 on its clip group: the region is the + // half-size square grown by 10, as if the clipping path had been drawn at half size. + let group = |clip: Node| { + let mut g = clip_group(1, clip); + g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + g + }; + let scaled = group(clipping_path(2, vec![fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))])); + let mut half = clipping_path(2, vec![]); + if let NodeKind::Path { path, .. } = &mut half.kind { + *path = shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)); + } + let want = bounds(&group(half)); + let got = bounds(&scaled); + assert!((got.x0 - want.x0).abs() < 0.5 && (got.x1 - want.x1).abs() < 0.5, "{got:?} against {want:?}"); + assert!((want.x0 - 65.0).abs() < 0.5, "{want:?}"); + // The clip group as the canvas evaluates it for its effect, and as an export bakes it. + let evaluated = crate::evaluate_container(None, &scaled).unwrap(); + let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") }; + let b = clip_outline(&children[0]).unwrap().0.bounding_box(); + assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "evaluated: {b:?} against {want:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, scaled).unwrap(); + let b = clip_outline(&baked_clip(&d)).unwrap().0.bounding_box(); + assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "exported: {b:?} against {want:?}"); +} + +#[test] +fn nested_effects_apply_before_the_clip_groups() { + // The clip group grows its clipping path by 10 (Offset Path). Inside the clipping path a + // Transform moves a member 60 pt right: the region grows from the moved member. + let evaluated = |clip: Node| { + let mut g = clip_group(1, clip); + g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + let e = crate::evaluate_container(None, &g).unwrap(); + let NodeKind::Group { children, clip: true } = &e.kind else { panic!("not a clip group") }; + clip_outline(&children[0]).unwrap().0.bounding_box() + }; + let moved = || clipping_path(3, vec![fx("distort.transform", json!({"moveH": 60}))]); + let close = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5; + let want = Rect::new(100.0, 40.0, 220.0, 160.0); + // A group holding the member. + let b = evaluated(Node::group(NodeId(2), vec![Arc::new(moved())])); + assert!(close(b, want), "group: {b:?}"); + // A compound path of the member, with a Transform of its own moving it 10 pt down. + let mut compound = Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(moved())], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let b = evaluated(compound); + assert!(close(b, Rect::new(100.0, 50.0, 220.0, 170.0)), "compound: {b:?}"); +} + +#[test] +fn clipping_type_keeps_its_effect_where_other_type_stays_type() { + // Type moved 30 pt right by its Transform clips a red square, beside other type, in a group + // that a Transform moves 10 pt down: the export keeps the other type as type and outlines the + // clipping type, which still clips where the canvas clips. + use vectorcraft_doc::text::{CharStyle, TextObject}; + let text = |id: u64, effects: Vec| { + let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut t = Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style)))); + t.appearance.effects = effects; + t + }; + let clipped = clip_group(2, text(3, vec![fx("distort.transform", json!({"moveH": 30}))])); + let mut outer = Node::group(NodeId(1), vec![Arc::new(clipped), Arc::new(text(5, vec![]))]); + outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + fn clip_of(n: &Node) -> Option { + match &n.kind { + NodeKind::Group { children, clip: true } => Some(clip_outline(children.first()?)?.0.bounding_box()), + _ => n.children()?.iter().find_map(|c| clip_of(c)), + } + } + if vectorcraft_text::CRAFT_FONTS.is_empty() { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let plain = clip_outline(&text(3, vec![])).unwrap().0.bounding_box(); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()).unwrap(); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + let mut exported = None; + for l in &baked.layers { + exported = exported.or_else(|| clip_of(l)); + } + let exported = exported.unwrap(); + assert!((canvas.x0 - plain.x0 - 30.0).abs() < 0.5 && (canvas.y0 - plain.y0 - 10.0).abs() < 0.5, "{canvas:?} from {plain:?}"); + assert!((exported.x0 - canvas.x0).abs() < 0.5 && (exported.y0 - canvas.y0).abs() < 0.5, "exported {exported:?}, canvas {canvas:?}"); + // The outlined clipping type keeps its black fill, which paints behind the clipped art. + let outlined = crate::reshape::clipping_text_outline(&text(3, vec![fx("distort.transform", json!({"moveH": 30}))])).unwrap(); + fn fills(n: &Node) -> usize { + n.appearance.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(f) if f.paint != Paint::None)).count() + + n.children().into_iter().flatten().map(|c| fills(c)).sum::() + } + assert!(fills(&outlined) > 0, "{outlined:?}"); +} + +#[test] +fn a_painted_clipping_paths_gradient_follows_its_effect() { + // A clipping path filled with a gradient placed across it, moved 60 pt right by its Transform + // effect, in a clip group with an effect of its own: the gradient moves with the path. + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + let geom = GradientGeom { start: (50.0, 100.0).into(), end: (150.0, 100.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut clip = clipping_path(2, vec![fx("distort.transform", json!({"moveH": 60}))]); + clip.appearance = Appearance { effects: clip.appearance.effects.clone(), ..Appearance::basic(placed, Paint::None, 0.0) }.into(); + let mut group = clip_group(1, clip); + group.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0}))); + let evaluated = crate::evaluate_container(None, &group).unwrap(); + let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") }; + let Paint::Gradient(g) = children[0].appearance.fill_paint() else { panic!("no gradient") }; + let start = g.geom.unwrap().start; + assert!((start.x - 110.0).abs() < 1e-6, "the gradient starts at the moved path's left edge: {start:?}"); +} + +#[test] +fn a_nested_clip_groups_hidden_clipping_path_keeps_its_effect() { + // A clip group used as a clipping path clips by its clipping path whether it is shown or not. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]); + hidden.visible = false; + let outer = clip_group(1, clip_group(3, hidden)); + d.insert(Some(layer), 0, outer.clone()).unwrap(); + let NodeKind::Group { children, .. } = &outer.kind else { panic!("not a group") }; + let b = bounds(&children[0]); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}"); + assert_eq!(clip_outline(&children[0]), clip_outline(&baked_clip(&d)), "the canvas clips by the outline the export writes"); +} + +#[test] +fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() { + // The clipping path's fill has a Transform effect moving it 60 pt right; the path has none. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let mut clip = clipping_path(2, vec![]); + clip.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("distort.transform", json!({"moveH": 60}))); + } + } + let group = clip_group(1, clip); + d.insert(Some(layer), 0, group.clone()).unwrap(); + let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") }; + let live = clip_outline(&children[0]).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live.0.bounding_box(), exported.0.bounding_box(), "the canvas clips by the outline the export writes"); + assert!((live.0.bounding_box().x1 - 210.0).abs() < 1e-6, "the moved fill reaches 210: {:?}", live.0.bounding_box()); + // A path of a compound clipping path with a fill effect of its own stays part of the region. + let mut member = clipping_path(4, vec![]); + member.appearance = children[0].appearance.clone(); + let other = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()); + let compound = + Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(member), Arc::new(other)], rule: vectorcraft_geom::FillRule::NonZero }); + assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0)); +} + +#[test] +fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() { + // Offset Path 10 on the fill of an unpainted clipping path, in a group that its Transform + // scales to 50 %: the region is the grown square at half size, 60 pt across, as it is when the + // clipping path is the member of a group that clips by its members. + let clip_with = |fill: Paint| { + let mut clip = clipping_path(3, vec![]); + clip.appearance = Appearance::basic(fill, Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + } + } + clip + }; + let scaled = |clip: Node| { + let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip))]); + outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))); + outer + }; + fn first_clip(n: &Node) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(|c| first_clip(c)), + } + } + let clip_of = |n: &Node| clip_outline(&first_clip(n).unwrap()).unwrap().0.bounding_box(); + let close = |r: Rect| (r.width() - 60.0).abs() < 0.5 && (r.height() - 60.0).abs() < 0.5; + let nested = clip_of(&crate::evaluate_container(None, &scaled(Node::group(NodeId(4), vec![Arc::new(clip_with(Paint::None))]))).unwrap()); + assert!(close(nested), "nested: {nested:?}"); + let outer = scaled(clip_with(Paint::None)); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()); + assert!(close(canvas), "canvas: {canvas:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap(); + assert!(close(exported), "exported: {exported:?}"); + // A clipping path that paints its fill keeps it, a path with the fill's effect live. + let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap(); + assert!(painted.clip_paint().fill.is_some(), "{painted:?}"); +} + +#[test] +fn a_baked_clipping_path_gives_its_pieces_ids_of_their_own() { + // An unpainted clipping path whose fill and stroke have Transform effects, in a clip group + // without effects: the export bakes it into a piece for each, every object with its own id. + let mut d = Document::new(400.0, 400.0); + let (group, path) = (d.alloc_id(), d.alloc_id()); + let mut clip = clipping_path(path.0, vec![]); + clip.appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into(); + for item in &mut clip.appearance.items { + match item { + vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("distort.transform", json!({"moveH": 60}))), + vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveV": 60}))), + } + } + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(group.0, clip)).unwrap(); + let baked = bake_document(&d).unwrap(); + let clip = baked_clip(&d); + assert_eq!(clip.children().map(|c| c.len()), Some(2), "{clip:?}"); + fn ids(n: &Node, out: &mut Vec) { + out.push(n.id); + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let mut all = vec![]; + for l in &baked.layers { + ids(l, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + +#[test] +fn a_hidden_member_with_an_effect_adds_nothing() { + let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default()); + let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": 200}))]); + hidden.visible = false; + let group = Node::group(NodeId(2), vec![Arc::new(shown), Arc::new(hidden)]); + assert_eq!(bounds(&group), Rect::new(50.0, 50.0, 100.0, 100.0)); +} + +#[test] +fn raster_and_hidden_effects_leave_the_clip_as_it_is() { + let mut hidden = fx("distort.transform", json!({"moveH": 60})); + hidden.visible = false; + for effects in [vec![], vec![fx("blur.gaussian", json!({"radius": 10}))], vec![hidden]] { + assert_eq!(bounds(&clipping_path(1, effects)), Rect::new(50.0, 50.0, 150.0, 150.0)); + } +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 19695fd4ff..a2d0316913 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -892,6 +892,10 @@ pub use buildcmds::live_paint_gaps; /// Control bar enable it when it changes one). pub use brushsym::reset_transformation_changes; +/// The byte order of UTF-16 text without a byte-order mark, by Import Data Sets' rule (the Graph +/// Data window reads files by it too). +pub use variablecmds::utf16_order_without_mark; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index d7cae868ed..b6b0669f12 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; 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 (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 (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() })) } @@ -516,16 +518,18 @@ fn sort(s: &mut Session, p: &Value) -> Result { } let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect(); if apply == "opacity" { - if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) { + // The opacity each object is given, as stored; an object whose stored opacity it already is has not changed. + let given: Vec<(NodeId, f32)> = wanted.iter().map(|(o, v)| (o.id, (v / 100.0) as f32)).collect(); + let changed: Vec = given.iter().filter(|(id, a)| d.node(*id).is_some_and(|n| n.opacity != *a)).map(|(id, _)| *id).collect(); + if changed.is_empty() { return Ok(count); } s.edit("Sort Objects", |d, _| { - for (o, v) in &wanted { - if let Some(n) = d.node_mut(o.id) { - n.opacity = (v / 100.0) as f32; + for (id, a) in &given { + if let Some(n) = d.node_mut(*id) { + n.opacity = *a; } } - let changed: Vec = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect(); keep_series_opacity(d, &changed); Ok(()) })?; 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/color.rs b/crates/engine/src/cmd/addons/color.rs index 2fe9c77a36..6eb21c9a63 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed}", has_selection, filter ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Print Ink Fixes", ["Object", "Plug-ins", "Color Effects"], None, - "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", has_doc, print_fixes::run ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Apply Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", has_selection, nearest::apply ), 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/appearance.rs b/crates/engine/src/cmd/appearance.rs index bed4b33b76..97230cec0e 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -529,9 +529,11 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke /// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters). -/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on -/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and -/// when `f` leaves every target as it was, the document and its undo history stay as they are. +/// What the edit changes on the topmost visible fill and stroke of graph series marks, and on the +/// rows and characters of graph labels, is stored on their graphs in the same step +/// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not +/// an edit, and when `f` leaves every target as it was, the document and its undo history stay as +/// they are. pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], @@ -541,7 +543,12 @@ pub(crate) fn edit_items( fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, ) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) + edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| { + let series = capture_series_changes(before, d, edited); + // The rows `f` edited are kept by `edit_items_then`. + let labels = super::graph::capture_label_changes(before, d, edited, false); + Ok(series || labels) + }) } /// [`edit_items`], then `after` on the document as it was before the edit, the edited document and 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.rs b/crates/engine/src/cmd/buildcmds.rs index 3fc5953619..06ec06e348 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Live Paint"], None, - "{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of their painted faces and edges → {ids}", + "{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of the faces and edges that draw, with all their fill and stroke rows: a shown face or edge with an opacity above 0 and a shown fill or stroke row that has paint and an opacity above 0 (a stroke also a weight above 0), whichever row is on top → {ids}", has_selection_or_ids, lp_expand ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index fb9e6c32ac..9c3c750996 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,7 @@ use super::{ - Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, - Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, - json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect, + Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json, + number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -84,6 +84,35 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } +/// What Merge carries from a face or edge to the part it makes again in its place, another path: +/// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep +/// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind. +struct Carried { + appearance: Appearance, + visible: bool, + opacity: f32, + blend: vectorcraft_color::BlendMode, +} + +impl Carried { + fn of(n: &Node) -> Self { + let mut appearance = (*n.appearance).clone().without_pressure(); + if appearance.has_unplaced_gradient() + && let Some(bounds) = n.geometric_bounds() + { + appearance.pin_gradients(bounds); + } + Self { appearance, visible: n.visible, opacity: n.opacity, blend: n.blend } + } + + fn onto(self, n: &mut Node) { + n.appearance = self.appearance.into(); + n.visible = self.visible; + n.opacity = self.opacity; + n.blend = self.blend; + } +} + /// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its /// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which /// are merged; returns (group, faces, edges). @@ -95,18 +124,16 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; check_live_paint_takes(d, roots)?; let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - // Paint to carry over: (point inside face, fill) and (point on edge, stroke). - let keep_faces: Vec<(Point, Paint)> = b::faces(&old) - .iter() - .filter(|f| !f.appearance.fill_paint().is_none()) - .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) - .collect(); - let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) + // Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge, + // paint) for every edge. + let keep_faces: Vec<(Point, Carried)> = + b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect(); + let keep_edges: Vec<(Point, Carried)> = b::edges(&old) .iter() .filter_map(|e| { let pd = e.path_data()?; let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone())) + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e))) }) .collect(); let mut sources = source_nodes(&old); @@ -126,20 +153,20 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result *ch = children; } g.extras_mut().live_paint_gaps = stored_gap_options(options); - for (pt, paint) in keep_faces { + for (pt, carried) in keep_faces { if let Some(fid) = face_at(&g, pt).map(|f| f.id) && let Some(ch) = g.children_mut() && let Some(f) = ch.iter_mut().find(|c| c.id == fid) { - Arc::make_mut(f).appearance.set_fill(paint); + carried.onto(Arc::make_mut(f)); } } - for (pt, app) in keep_edges { + for (pt, carried) in keep_edges { if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) && let Some(ch) = g.children_mut() && let Some(e) = ch.iter_mut().find(|c| c.id == eid) { - Arc::make_mut(e).appearance = app.into(); + carried.onto(Arc::make_mut(e)); } } let mut merged = Beside::replacing(d, roots); @@ -191,6 +218,13 @@ pub(super) fn lp_release(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&ids) })) } +/// Does face or edge `n` draw on the canvas: it is shown, its opacity is above 0, and a fill or +/// stroke row of its appearance draws (see [`Appearance::has_drawn_fill`] and +/// [`Appearance::has_drawn_stroke`])? +fn draws(n: &Node) -> bool { + n.visible && n.opacity > 0.0 && (n.appearance.has_drawn_fill() || n.appearance.has_drawn_stroke()) +} + pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result { let targets = live_targets(s, p)?; s.edit("Expand Live Paint", |d, sel| { @@ -205,8 +239,7 @@ pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result { if let Some(ch) = n.children_mut() { ch.retain(|c| match c.name.as_deref() { Some(SOURCES_NAME) => false, - Some(FACE_NAME) => !c.appearance.fill_paint().is_none(), - Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0, + Some(FACE_NAME | EDGE_NAME) => draws(c), _ => true, }); } 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/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 2395c51f8e..8377822094 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -513,14 +513,16 @@ pub(crate) fn edit_if_changed(s: &mut Session, label: &str, f: impl FnOnce(&D } /// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed -/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn -/// again. +/// on the graph series marks and graph labels among `roots` and inside them, so the marks and every label of a kind keep +/// their new paint when the graph is drawn again. pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result) -> Result { let marks = super::graph::series_marks_in(d, roots); - let before = (!marks.is_empty()).then(|| d.clone()); + let labels = super::graph::labels_in(d, roots); + let before = (!marks.is_empty() || !labels.is_empty()).then(|| d.clone()); let n = recolor(d)?; if let Some(before) = before.filter(|_| n > 0) { super::appearance::capture_series_changes(&before, d, &marks); + super::graph::capture_label_changes(&before, d, &labels, true); } Ok(n) } 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/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index 434f2e383a..cac6f4bdcb 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -478,6 +478,75 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// The label of dimension `id` in `doc`. +fn label(doc: &vectorcraft_doc::Document, id: u64) -> vectorcraft_doc::TextObject { + let node = doc.node(NodeId(id)).unwrap(); + node.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap() +} + +/// The center of label `t`'s measured box on the page. +fn label_center(t: &vectorcraft_doc::TextObject) -> vectorcraft_geom::Point { + t.xf * t.cached_bounds.unwrap().center() +} + +/// Whether every character of label `t` is filled in CMYK. +fn label_in_cmyk(t: &vectorcraft_doc::TextObject) -> bool { + !t.runs.is_empty() && t.runs.iter().all(|r| matches!(r.style.fill.color(), Some(vectorcraft_color::Color::Cmyk { .. }))) +} + +/// The native file of a CMYK document whose composite font `name` sets Roman at full size, with a +/// dimension whose label is set in it and whose label's characters are saved in RGB, so opening +/// the file draws the dimension again → (the file, the dimension's id, the center of its label as +/// drawn in the document's own composite font). +fn dimension_file(name: &str) -> (Vec, u64, vectorcraft_geom::Point) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "colorMode": "cmyk"})).unwrap(); + let mut full = font(name); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let line = s.execute("shape.line", &json!({"x1": 100, "y1": 100, "x2": 172, "y2": 100})).unwrap()["id"].as_u64().unwrap(); + let id = s + .execute( + "dimension.create", + &json!({"source": {"kind": "segment", "edge": {"node": line, "subpath": 0, "segment": 0}}, + "options": {"units": "in", "precision": 2, "fontFamily": name, "fontSize": 20}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let drawn = label(&doc, id); + assert!(label_in_cmyk(&drawn)); + let center = label_center(&drawn); + let mut label_id = None; + doc.node(NodeId(id)).unwrap().walk(&mut |n| { + if matches!(n.kind, NodeKind::Text(_)) { + label_id = Some(n.id); + } + }); + if let Some(NodeKind::Text(t)) = doc.node_mut(label_id.unwrap()).map(|n| &mut n.kind) { + for r in &mut t.runs { + r.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0)); + } + } + (vectorcraft_format::save_file(&doc), id, center) +} + +/// A file whose dimensions are drawn again when it opens (their art is not in the document's color +/// mode) draws their labels in its own composite fonts, not in those of the front document, which +/// defines the label's font name with Roman at half size: each label stays centered on its anchor. +#[test] +fn an_opened_file_draws_its_dimension_labels_in_its_own_composite_fonts() { + let (bytes, id, center) = dimension_file("CF Dimension"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Dimension")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("dimension.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let opened = label(&s.doc().unwrap().doc, id); + assert!(label_in_cmyk(&opened)); + assert!((label_center(&opened) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&opened)); +} + /// Threaded type read from a file flows through its frames in the file's own composite fonts, when /// no open document defines them. #[test] @@ -536,6 +605,40 @@ fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// A document replaced in front (File › Revert) whose dimensions are drawn again draws their labels +/// in its new composite fonts, not in those it had before. +#[test] +fn a_document_replaced_in_front_draws_its_dimension_labels_in_its_own_composite_fonts() { + let (bytes, id, center) = dimension_file("CF Reverted"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Reverted")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + let replaced = label(&s.doc().unwrap().doc, id); + assert!(label_in_cmyk(&replaced)); + assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced)); +} + +/// A document replaced in the back draws its dimension labels in its own composite fonts, while the +/// front document keeps its definition of the same name registered; the labels stay where they are +/// when the document comes to the front. +#[test] +fn a_document_replaced_in_the_back_draws_its_dimension_labels_in_its_own_composite_fonts() { + let (bytes, id, center) = dimension_file("CF Back Dimension"); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Dimension")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert_eq!(s.active_index(), Some(1)); + let front = vectorcraft_text::FontDb::global().composite_font("CF Back Dimension").unwrap(); + assert_eq!(front.parts.get(vectorcraft_doc::CharClass::Roman).size, 50.0); + let replaced = label(&s.documents()[0].doc, id); + assert!(label_in_cmyk(&replaced)); + assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced)); + assert!(s.set_active(0)); + let shown = label(&s.doc().unwrap().doc, id); + assert!((label_center(&shown) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&shown)); +} + /// The type of document `index` with id `id`, wherever its tab is. fn text_in(s: &Session, index: usize, id: u64) -> vectorcraft_doc::TextObject { match &s.documents()[index].doc.node(NodeId(id)).unwrap().kind { diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index 44ac08d7ad..183cbd3746 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -495,7 +495,8 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { /// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved /// before dimensions followed the colour mode have RGB dimensions in CMYK documents. A dimension -/// that can't be redrawn keeps the art it has. +/// that can't be redrawn keeps the art it has. The labels are measured in the document's own +/// composite fonts, also when another document's definitions of the same names are registered. pub(crate) fn conform(doc: &mut Document) { if !doc.dimensions { return; @@ -521,6 +522,10 @@ pub(crate) fn conform(doc: &mut Document) { } } }); + if stale.is_empty() { + return; + } + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); for (id, d) in stale { // An error leaves this dimension as it was; the others are still redrawn. let _ = replace(doc, id, d); 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/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 4068c2506c..b5ac1c13e5 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -344,44 +344,9 @@ fn spot_swatches(d: &Document) -> HashSet<&str> { first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect() } -/// Which of gradient `g`'s stops color some part of it that draws, by the stretches -/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k` -/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s, -/// and both its stops count when that stretch has positive width and either stop is above opacity -/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its -/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop, -/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width -/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from -/// its points, so each of its stops counts while any stop is above opacity 0. -fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec { - let stops = &g.stops; - if g.kind == vectorcraft_color::GradientKind::Freeform { - let any = stops.iter().any(|s| s.opacity > 0.0); - return vec![any; stops.len()]; - } - let mut drawn = vec![false; stops.len()]; - if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) { - *d = first.opacity > 0.0; - } - if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) { - *d |= last.opacity > 0.0; - } - // `f32::max` skips a NaN offset, which the sampler never picks either. - let mut reach = f32::NEG_INFINITY; - for k in 0..stops.len().saturating_sub(1) { - reach = reach.max(stops[k].offset); - let (a, b) = (&stops[k], &stops[k + 1]); - if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) { - drawn[k] = true; - drawn[k + 1] = true; - } - } - drawn -} - /// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is -/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the -/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a +/// linked to, or those a gradient's drawn stops ([`vectorcraft_color::Gradient::drawn_stops`]) are +/// linked to, each once, in the order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a /// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not /// linked to one. fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { @@ -398,7 +363,7 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { match p { Paint::Solid { swatch, tint, .. } => add(swatch, *tint), Paint::Gradient(g) => { - let drawn = drawn_stops(&g.gradient); + let drawn = g.gradient.drawn_stops(); g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint)); } _ => {} @@ -1662,39 +1627,6 @@ mod tests { assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}"); } - /// A gradient's stops print where they color a stretch that draws, for stops in any order: the - /// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an - /// out-of-order stop's pair can have no width. - #[test] - fn drawn_stops_follow_the_sampled_stretches() { - use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop}; - let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) }; - let g = |stops: Vec| Gradient::new(GradientKind::Linear, stops); - // In order: the clear stop next to the opaque one, not the one after it. - assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]); - // Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with - // the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8. - assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]); - // An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the - // pairs next to the stop at 0.3 have no width and it colors nothing. - let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]); - assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]); - // With the stop at 0.8 opaque, the pairs next to it with width are drawn. - assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]); - // The last stop colors the offsets above every stop's, even when it is out of order. - assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]); - // The first stop colors the offsets up to its own. - assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]); - // One stop, every stop clear, and no stops at all. - assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]); - assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]); - assert!(drawn_stops(&g(vec![])).is_empty()); - // A freeform gradient draws from its points: its stops count while any is above opacity 0. - let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]); - free.kind = GradientKind::Freeform; - assert_eq!(drawn_stops(&free), vec![true, true, true]); - } - /// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object, /// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of /// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 53949fdaf0..ce067ddc9c 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { "Pencil", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end, and closes it when `closed` is true (left out, when the stroke ends within four Fidelities of the path's other end, 6 to 400 pt); with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", has_doc, freehand ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 0439aaa081..401bdd2977 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -217,11 +217,18 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { } None => None, }; - // An extending stroke that ends within four Fidelities of the path's other end closes it: - // at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. + // `closed`, when given, says whether the stroke closes the path: the Pencil and the + // Paintbrush send it, by their Close Paths When Ends Are Within and Alt at the release. + // Left out, an extending stroke that ends within four Fidelities of the path's other end + // closes it: at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. let close_tol = (tol * 4.0).clamp(6.0, 400.0); - let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); - if closing && let Some(l) = pts.last_mut() { + let near_first = pts.last().is_some_and(|p| p.distance(first) <= close_tol); + let wants_close = if p.get("closed").is_some_and(|v| !v.is_null()) { closed } else { near_first }; + let closing = connect.is_none() && sp.anchors.len() >= 2 && wants_close; + // A stroke ending near the other end ends on it; one ending farther away closes with a + // straight segment back to it. + let ends_on_first = closing && near_first; + if ends_on_first && let Some(l) = pts.last_mut() { *l = first; } if let Some((_, _, y, _)) = &connect @@ -238,13 +245,23 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { add.remove(0); } sp.anchors.extend(add); - if closing + if ends_on_first && sp.anchors.len() > 2 && let Some(l) = sp.anchors.pop() && let Some(a0) = sp.anchors.first_mut() { a0.h_in = l.h_in; sp.closed = true; + } else if closing && !ends_on_first && sp.anchors.len() > 2 { + // Closed from away from the other end: a straight segment back to it, whatever + // handles the open ends carried. + sp.closed = true; + if let Some(l) = sp.anchors.last_mut() { + l.h_out = l.p; + } + if let Some(f) = sp.anchors.first_mut() { + f.h_in = f.p; + } } for a in &mut sp.anchors { *a = Anchor::with_handles(a.p, a.h_in, a.h_out); 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.rs b/crates/engine/src/cmd/fileio.rs index 1ff1897c85..84fb121d0c 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -70,7 +70,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated}; -pub use export::{export_doc, export_source, unused_option_notes}; +pub use export::{export_doc, export_source, selection_doc, unused_option_notes}; use load::err; pub(crate) use load::import_svg; pub(crate) use load::is_placeholder; diff --git a/crates/engine/src/cmd/fileio/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 03736c6022..ca74243699 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", has_selection, export::export_selection ), @@ -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/export.rs b/crates/engine/src/cmd/fileio/export.rs index 594193cd7b..a2e9393ee0 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -295,8 +295,14 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C /// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a /// text export holds the type of the selected objects and not that of a clipping path around them. fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> { - let ids = selected_artwork(s)?; - let doc = &s.doc()?.doc; + selection_doc(s.doc()?, cmd, text) +} + +/// The document an Export Selection of `st` writes and its crop, as `document.exportSelection` +/// writes it ([`selection`]); `cmd` names the command in the error when nothing selected draws. +pub fn selection_doc(st: &DocState, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> { + let ids = artwork_of(st); + let doc = &st.doc; let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?; if text { let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); @@ -312,12 +318,16 @@ fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vector /// layers are guides and are left out. With an object of a dynamic symbol instance's art selected, /// the instance is exported whole. pub(crate) fn selected_artwork(s: &Session) -> Result> { - let st = s.doc()?; + Ok(artwork_of(s.doc()?)) +} + +/// [`selected_artwork`] of document `st`. +fn artwork_of(st: &DocState) -> Vec { let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)); let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); - Ok(ids.into_iter().filter(|id| !on_template(*id)).collect()) + ids.into_iter().filter(|id| !on_template(*id)).collect() } /// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer diff --git a/crates/engine/src/cmd/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.rs b/crates/engine/src/cmd/fileio/tests.rs index d17af84b66..7374e26e2b 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -186,6 +186,22 @@ fn planar_tiffs_read_as_their_chunky_versions() { } } +/// A 16-bit CMYK TIFF with alpha places with its alpha rounded from 16 bits, planar as chunky. +#[test] +fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() { + let none = vec![0u16; 6]; + let cyan = vec![129u16, 0, 300, 0, 128, 65535]; + let alpha = vec![65535u16, 129, 386, 0, 256, 65535]; + for extra in [1u16, 2] { + let channels = [cyan.clone(), none.clone(), none.clone(), none.clone(), alpha.clone()]; + let chunky = kept(&format!("extra samples {extra}, chunky"), &hand_tiff(5, 16, &[extra], false, &channels)); + let planar = kept(&format!("extra samples {extra}, planar"), &hand_tiff(5, 16, &[extra], true, &channels)); + assert_eq!(planar, chunky, "extra samples {extra}: planar reads as chunky"); + let alphas: Vec = chunky.1.iter().skip(3).step_by(4).copied().collect(); + assert_eq!(alphas, [255, 1, 2, 0, 1, 255], "extra samples {extra}: the alpha, rounded from 16 bits"); + } +} + /// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows /// a strip each, times 5 planes) is refused as too large to draw, without a panic. #[test] 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/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index eb3989597f..121cc0b279 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -352,20 +352,8 @@ fn svg_darkness_preserves_source_model_and_gradient_appearance() { #[test] fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() { - const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE"; - if std::env::var_os(CHILD).is_none() { - // Color settings are process-wide: exercise this profile in a separate test process. - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--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 color profile test failed:\n{stdout}\n{}", - String::from_utf8_lossy(&output.stderr), - ); + // Color settings are process-wide: exercise this profile in a separate test process. + if !crate::tests::in_own_process("cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space") { return; } use vectorcraft_color::{Color, Paint, cms}; 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/freeform.rs b/crates/engine/src/cmd/freeform.rs index bdba729211..5f2a5e4070 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Add Freeform Point", [], None, - "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}", + "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index b47fd0e5e3..2fee4186cc 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label", has_doc, edit_gradient ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 20332742bd..da23bd4f04 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -45,10 +45,10 @@ use generate::generate; #[cfg(test)] use series::default_series_color; pub(crate) use series::{ - LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, - capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art, - label_object_opacity_differs, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, - shown_paint, + LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, + capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art, + label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, + series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 69510652b9..fe8ffb98f8 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,7 @@ use super::labels::{ recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; -use super::series::{series_marks, with_series_gradients, with_series_opacity}; +use super::series::{picks_legend_fill, series_marks, with_series_gradients, with_series_opacity}; use super::{ Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, }; @@ -397,8 +397,7 @@ impl<'a> Plot<'a> { let at = ring.centre + dir(mid) * (ring.hole + (ring.rad - ring.hole) * 0.62); let t = self.b.label(Role::Legend, Point::new(at.x, at.y + self.ls * 0.35), &label, Justify::Center, true); // White on a dark wedge, unless the legend labels were given a colour of their own. - let own_colour = self.g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); - self.wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); + self.wedge_labels.push(if dark && picks_legend_fill(self.g) { recolour_text(&t, Color::WHITE) } else { t }); } /// The pie's category name. diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index aeaceb9e91..b5acb1ba66 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -626,20 +626,27 @@ pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], metho pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { let mut changed = false; for (graph, role) in label_kinds(doc, ids) { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - let mut style = label_style(&spec.label_styles, role); - change(&mut style); - let slot = match role { - Role::Value => &mut spec.label_styles.values, - Role::Category => &mut spec.label_styles.categories, - Role::Legend => &mut spec.label_styles.legend, - }; - changed |= slot.as_ref() != Some(&style); - *slot = Some(style); + changed |= store_label_style(doc, graph, role, change); } changed } +/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether +/// the kept style changed. +fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false }; + let mut style = label_style(&spec.label_styles, role); + change(&mut style); + let slot = match role { + Role::Value => &mut spec.label_styles.values, + Role::Category => &mut spec.label_styles.categories, + Role::Legend => &mut spec.label_styles.legend, + }; + let changed = slot.as_ref() != Some(&style); + *slot = Some(style); + changed +} + /// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels /// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character /// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes. @@ -807,6 +814,144 @@ pub(crate) fn capture_label_text( Ok(changed) } +/// The character fill and stroke attributes a kind of graph label keeps when a recolor, or a stroke or gradient edit +/// aimed at the characters, changes them on its labels ([`capture_label_changes`]). Each entry copies one attribute +/// from the first style to the second and returns whether they differed. +const RUN_PAINT: [fn(&CharStyle, &mut CharStyle) -> bool; 8] = [ + |a, b| keep(&a.fill, &mut b.fill), + |a, b| keep(&a.stroke, &mut b.stroke), + |a, b| keep(&a.stroke_width, &mut b.stroke_width), + |a, b| keep(&a.stroke_cap, &mut b.stroke_cap), + |a, b| keep(&a.stroke_join, &mut b.stroke_join), + |a, b| keep(&a.stroke_miter_limit, &mut b.stroke_miter_limit), + |a, b| keep(&a.stroke_dash, &mut b.stroke_dash), + |a, b| keep(&a.stroke_align, &mut b.stroke_align), +]; + +/// Copy `from` to `to`. Returns whether they differed. +fn keep(from: &T, to: &mut T) -> bool { + let differs = from != to; + if differs { + to.clone_from(from); + } + differs +} + +/// The [`RUN_PAINT`] attributes an edit changed on the characters of graph label `now` (`was`: the label before the +/// edit), as the label's style and the indexes of the changed entries. An attribute counts only when every character +/// of the label shows it alike, and the fill only when `fill` is set. A placed gradient is kept without its placement, +/// so it fits each label it lands on. The stroke weight and dashes are kept as the label shows them: the graph draws its +/// labels again without the label's own scale. +fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Vec)> { + let (NodeKind::Text(was), NodeKind::Text(now)) = (&was.kind, &now.kind) else { return None }; + let first_now = &now.runs.first()?.style; + // Changed: some character showed something else before the edit, whichever run it is in. + let changed: Vec = RUN_PAINT + .iter() + .enumerate() + .filter(|(i, copy)| { + (fill || *i != 0) + && now.runs.iter().all(|r| !copy(first_now, &mut r.style.clone())) + && was.runs.iter().any(|r| copy(first_now, &mut r.style.clone())) + }) + .map(|(i, _)| i) + .collect(); + if changed.is_empty() { + return None; + } + let unplaced = crate::cmd::gradient::unplaced; + let mut style = first_now.clone(); + (style.fill, style.stroke) = (unplaced(&style.fill), unplaced(&style.stroke)); + // Character strokes are drawn in text space, scaled by the type's transform. + if let Some(k) = vectorcraft_doc::Scaling::factor(now.xf).filter(|k| k.is_finite() && *k > 1e-12) { + let mut st = style.stroke_layer(); + crate::inspect::scale_layer(&mut st, k); + style.set_stroke_layer(&st); + } + Some((style, changed)) +} + +/// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with +/// Legends in Wedges whose legend labels keep no fill of their own? +pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool { + g.kind == GraphKind::Pie + && g.legend + && g.pie_legend_in_wedges + && !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)) +} + +/// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them. +pub(crate) fn labels_in(doc: &Document, roots: &[NodeId]) -> Vec { + let mut texts = Vec::new(); + for id in roots { + let Some(root) = doc.node(*id) else { continue }; + root.walk(&mut |n| { + if matches!(n.kind, NodeKind::Text(_)) { + texts.push(n.id); + } + }); + } + // Graph labels are type: without type among `roots`, the graph's labels are not looked up. + if texts.is_empty() { + return texts; + } + let labels = graph_labels(doc); + texts.retain(|id| labels.contains_key(id)); + // Nested roots (a graph and one of its labels) reach a label twice. + texts.sort_unstable(); + texts.dedup(); + texts +} + +/// Keep for every label of their kind what an edit changed on graph labels `ids` (`before`: the document before the +/// edit): with `rows`, the object rows ([`capture_label_rows`]) of the labels whose rows changed, and the character fill +/// and stroke attributes ([`RUN_PAINT`]) that changed on all of a label's characters, on the kind's kept style. The +/// frontmost label of a kind sets what that kind keeps, whatever order `ids` are in. On a graph that colors its legend +/// labels itself ([`picks_legend_fill`]), their fill is kept only when the legend labels among `ids` end up with one +/// fill. Called inside the same edit, so they undo together. Returns whether what a graph keeps changed. +pub(crate) fn capture_label_changes(before: &Document, doc: &mut Document, ids: &[NodeId], rows: bool) -> bool { + if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return false; + } + let labels = graph_labels(doc); + let mut picked: Vec = ids.iter().copied().filter(|id| labels.contains_key(id)).collect(); + // A later label's capture replaces an earlier one's: take them in paint order, each once. + picked.sort_by_cached_key(|id| doc.index_path(*id)); + picked.dedup(); + let picks_fill = |graph: NodeId| doc.node(graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(picks_legend_fill); + // On a pie that colors its legend labels black or white by their wedge, a recolor of the whole pie flips them both + // ways: their fill is kept only when every legend label the edit reached ends up with the same one. + let mut wedge_fills: HashMap> = HashMap::new(); + for id in &picked { + let (Some(&(graph, Role::Legend)), Some(NodeKind::Text(t))) = (labels.get(id), doc.node(*id).map(|n| &n.kind)) else { continue }; + if picks_fill(graph) { + wedge_fills.entry(graph).or_default().extend(t.runs.iter().map(|r| r.style.fill.clone())); + } + } + let one_fill = |graph: NodeId| wedge_fills.get(&graph).is_none_or(|f| f.windows(2).all(|w| w[0] == w[1])); + let mut changed_rows = Vec::new(); + let mut characters = Vec::new(); + for (id, kind) in picked.iter().filter_map(|id| Some((*id, *labels.get(id)?))) { + let (Some(was), Some(now)) = (before.node(id), doc.node(id)) else { continue }; + if rows && label_rows(was) != label_rows(now) { + changed_rows.push(id); + } + let fill = kind.1 != Role::Legend || one_fill(kind.0); + characters.extend(run_paint_change(was, now, fill).map(|change| (kind, change))); + } + let mut changed = capture_label_rows(doc, &changed_rows); + for ((graph, role), (style, entries)) in &characters { + changed |= store_label_style(doc, *graph, *role, &|st| { + for i in entries { + if let Some(copy) = RUN_PAINT.get(*i) { + copy(style, st); + } + } + }); + } + changed +} + /// The graphs and kinds of label among `ids`, each once. fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> { if ids.is_empty() { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 45ce3e22e9..f56ec5a5dc 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -508,6 +508,89 @@ fn paint_targets_leave_a_graph_designs_copy_out() { assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]); } +/// The art of a marker design in series 1 of a line graph: the design's copy and a path inside it. +fn marker_design_art() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap(); + let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone(); + assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art"); + let mut art = vec![]; + marker.walk(&mut |m| { + if matches!(m.kind, NodeKind::Path { .. }) { + art.push(m.id); + } + }); + (s, marker.id, art[0]) +} + +/// The ids `graphicStyle.list` reports as linked to style `name`. +fn linked_to(s: &mut Session, name: &str) -> Vec { + let list = s.execute("graphicStyle.list", &json!({})).unwrap(); + let style = list["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap().clone(); + let mut ids: Vec = style["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + ids.sort_unstable(); + ids +} + +/// Graphic styles and the art of a column or marker design in a series (the design's copy and a path inside it): +/// `graphicStyle.new` makes a style from the art without linking it; `graphicStyle.redefine` leaves the art's look as +/// it was and unlinks it, whether the art is linked (as in a document saved before) or the source, while the other +/// linked objects follow the new look. +#[test] +fn graphic_styles_leave_a_graph_designs_art_unlinked() { + let make = |marker: bool| { + if marker { + marker_design_art() + } else { + let (s, _, copy, art, _) = design_art(); + (s, copy, art) + } + }; + let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone(); + let link = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style; + let link_directly = |s: &mut Session, ids: &[NodeId], style: u32| { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + for id in ids { + d.node_mut(*id).unwrap().extras_mut().graphic_style = Some(style); + } + }; + let rect = |s: &mut Session| { + NodeId(s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()) + }; + for marker in [false, true] { + for source in [0, 1] { + let at = format!("marker {marker}, source {source}"); + // New: the style is made from the art, which stays unlinked. + let (mut s, copy, art) = make(marker); + let source = [copy, art][source]; + s.execute("graphicStyle.new", &json!({"id": source.0, "name": "Art"})).unwrap(); + assert_eq!(link(&s, source), None, "{at}"); + assert_eq!(linked_to(&mut s, "Art"), Vec::::new(), "{at}"); + // Redefine from another object: linked art keeps its look and is unlinked; a linked rectangle follows. + let follower = rect(&mut s); + s.execute("graphicStyle.apply", &json!({"name": "Art", "ids": [follower.0]})).unwrap(); + let style = link(&s, follower).unwrap(); + link_directly(&mut s, &[copy, art], style); + let (copy_was, art_was) = (rows(&s, copy), rows(&s, art)); + let (blue_rect, blue) = blue_style(&mut s, "Blue", false); + s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": blue_rect.0})).unwrap(); + assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone()), "{at}"); + assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}"); + assert_eq!(rows(&s, follower).fill_paint(), blue, "{at}"); + assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}"); + // Redefine from the art itself: the art is unlinked and the linked rectangle takes the art's look. + link_directly(&mut s, &[copy, art], style); + s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": source.0})).unwrap(); + assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was), "{at}"); + assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}"); + assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}"); + } + } +} + /// The art of a marker design in a series is refused too. #[test] fn effect_commands_refuse_a_marker_designs_art() { @@ -757,6 +840,24 @@ fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() { assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6)); } +/// A Sort Objects that gives series marks the opacities they already have changes nothing: the value the graph stores +/// stays and no undo step is made, even when the evenly stepped percentage differs from a mark's in the last digits. +#[test] +fn an_opacity_sort_objects_that_changes_no_mark_keeps_the_stored_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 65})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the columns have 30%, 65% and 100%, the values an even step from 30% to 100% gives them. + let undo = s.doc().unwrap().history.undo.len(); + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.65, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); +} + /// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark /// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them /// when the graph is drawn again. @@ -5991,6 +6092,247 @@ fn a_graphic_style_on_a_label_is_kept_for_its_kind() { assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}"); } +/// The character style of the first run of label `text` in group `grp`. +fn label_run_style(s: &Session, id: NodeId, grp: &str, text: &str) -> CharStyle { + let NodeKind::Text(t) = label_node(s, id, grp, text).kind else { panic!("{text} is not type") }; + t.runs[0].style.clone() +} + +/// A recolor of the whole graph (Invert or Complement on the proxy, an Edit Colors command, a color filter or Recolor +/// Artwork) is kept for the characters and the rows of every kind of label when the graph is drawn again, and undo takes +/// it back with its edit. +#[test] +fn recoloring_a_graph_keeps_its_labels_paint_for_every_kind() { + let cases = [ + ("paint.invert", json!({})), + ("paint.complement", json!({})), + ("edit.colors.invert", json!({})), + ("edit.colors.toGrayscale", json!({})), + ("addon.color.filter", json!({"effect": "addon.color.invert"})), + ("recolor.apply", json!({"map": {"#3366cc": "#00ff00", "#ff8000": "#8000ff"}})), + ]; + every_case(cases, |(cmd, params)| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Characters of every label in a color that every case changes, and an orange fill row on the category labels. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap(); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff8000"})).unwrap(); + let was = spec(&s, id).label_styles; + let mut p = params.clone(); + if *cmd == "recolor.apply" { + // Recolor Artwork works on the selection. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + } else { + p["ids"] = json!([id.0]); + } + s.execute(cmd, &p).unwrap(); + let hex = |p: &Paint| p.color().map(|c| c.to_hex()).unwrap_or_default(); + let characters = hex(&label_run_style(&s, id, "Axes", "4").fill); + assert_ne!(characters, "#3366cc", "{cmd} recolors the characters"); + let row = label_node(&s, id, "Axes", "Q2").appearance.items[0].paint().clone(); + assert_ne!(hex(&row), "#ff8000", "{cmd} recolors the row"); + let looks = label_looks(&mut s, id); + for t in ["0", "Q1", "2024"] { + assert!(looks.iter().any(|(x, _, fill)| x == t && *fill == characters), "{cmd}: {t}: {looks:?}"); + } + let rows = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&rows, "Q1").0.first().map(|r| r.1.clone()), Some(hex(&row)), "{cmd}: {rows:?}"); + // Undo takes back the recolor and what the graph keeps (the two redraws are undone first). + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(spec(&s, id).label_styles, was, "{cmd}"); + }); +} + +/// A recolor of one label changes what the graph keeps for that kind of label only. +#[test] +fn recoloring_one_label_keeps_its_paint_for_its_kind_only() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("edit.colors.invert", &json!({"ids": [v.0]})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ffffff"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#000000"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "2024" && fill == "#000000"), "{looks:?}"); + let g = spec(&s, id).label_styles; + assert!(g.categories.is_none() && g.legend.is_none(), "{g:?}"); +} + +/// A recolor that leaves a label's first characters as they were and gives the rest the same fill is kept for its kind. +#[test] +fn a_recolor_that_evens_out_a_labels_characters_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let q2 = text_id(&s, id, "Axes", "Q2"); + s.select_tool("type", crate::ViewInfo::default()).unwrap(); + for (start, end, hex) in [(0, 1, "#ff0000"), (1, 2, "#0000ff")] { + s.set_tool_option("textSelection", &json!({"id": q2.0, "start": start, "end": end})); + s.execute("paint.setFill", &json!({"color": hex})).unwrap(); + } + s.select_tool("selection", crate::ViewInfo::default()).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories, None, "part of a label's text is not kept"); + s.execute("select.set", &json!({"ids": [q2.0]})).unwrap(); + s.execute("recolor.apply", &json!({"map": {"#0000ff": "#ff0000"}})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#ff0000"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#000000"), "{looks:?}"); +} + +/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` and placed with +/// `paint.setGradientGeom` on a label's characters are kept for every label of its kind, the gradient without its +/// placement. +#[test] +fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let category = label_run_style(&s, id, "Axes", "Q1"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3, "cap": "round"})).unwrap(); + s.execute("paint.editGradient", &json!({"ids": [v.0], "kind": "linear", "stroke": false})).unwrap(); + let placement = json!({"ids": [v.0], "stroke": false, "start": [100, 100], "end": [200, 100], "aspect": 50}); + s.execute("paint.setGradientGeom", &placement).unwrap(); + let fill = label_run_style(&s, id, "Axes", "4").fill; + assert!(matches!(&fill, Paint::Gradient(g) if g.geom.is_some()), "the edited label's gradient is placed: {fill:?}"); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((st.stroke_width, st.stroke_cap), (3.0, vectorcraft_doc::LineCap::Round)); + assert!(matches!(&st.fill, Paint::Gradient(g) if g.geom.is_none()), "{:?}", st.fill); + assert_eq!(label_run_style(&s, id, "Axes", "Q1"), category, "the category labels are left as they were"); +} + +/// `stroke.set`, `stroke.weightUp` and `stroke.weightDown` given the whole graph are kept for every kind of label. +#[test] +fn stroke_weights_set_on_the_whole_graph_are_kept_for_its_labels() { + let cases = [("stroke.set", json!({"weight": 2}), 2.0), ("stroke.weightUp", json!({}), 2.0), ("stroke.weightDown", json!({}), 0.75)]; + every_case(cases, |(cmd, params, weight)| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap(); + let mut p = params.clone(); + p["ids"] = json!([id.0]); + s.execute(cmd, &p).unwrap(); + assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, *weight, "{cmd}"); + label_looks(&mut s, id); + for (grp, t) in [("Axes", "0"), ("Axes", "Q1"), ("Legend", "2024")] { + assert_eq!(label_run_style(&s, id, grp, t).stroke_width, *weight, "{cmd}: {t}"); + } + }); +} + +/// A stroke weight set on a scaled label is kept as the label shows it, since the graph draws its labels unscaled. +#[test] +fn a_stroke_weight_on_a_scaled_label_is_kept_as_it_shows() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3})).unwrap(); + assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, 1.5, "drawn at twice its size"); + label_looks(&mut s, id); + assert_eq!(label_run_style(&s, id, "Axes", "0").stroke_width, 3.0); +} + +/// When the characters of two labels of one kind are recolored differently, the frontmost one sets what the kind +/// keeps, whatever order `ids` are in. +#[test] +fn the_frontmost_label_of_a_kind_sets_what_its_recolor_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Legend labels: the axes tell value and category labels apart by their order, so these are moved instead. + let (a, b) = (text_id(&s, id, "Legend", "2024"), text_id(&s, id, "Legend", "2025")); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap(); + // The label made first goes in front, so neither the order of `ids` nor the order the labels were made in decides. + let (front, back) = if a < b { (a, b) } else { (b, a) }; + s.execute("select.set", &json!({"ids": [front.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + let path = |n: NodeId| s.doc().unwrap().doc.index_path(n); + assert!(path(front) > path(back), "{:?} {:?}", path(front), path(back)); + let front_fill = if front == a { "#00ffff" } else { "#ffff00" }; + s.execute("edit.colors.invert", &json!({"ids": [front.0, back.0]})).unwrap(); + let kept = spec(&s, id).label_styles.legend.unwrap().fill; + assert_eq!(kept.color().map(|c| c.to_hex()).as_deref(), Some(front_fill)); +} + +/// Apply Nearest Swatch and the ink limit of Print Ink Fixes on a label are kept for its kind. +#[test] +fn nearest_swatch_and_ink_limit_on_a_label_are_kept_for_its_kind() { + let cases = [ + ("addon.color.applyNearestSwatch", json!({"library": "web-safe-216"})), + ("addon.color.printFixes", json!({"scope": "selection", "inkLimit": 200})), + ]; + every_case(cases, |(cmd, params)| { + let mut s = Session::new(); + // Print Ink Fixes works on CMYK documents. + s.execute("file.new", &json!({"width": 800, "height": 800, "colorMode": "cmyk"})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setFill", &json!({"ids": [v.0], "color": {"c": 90, "m": 80, "y": 70, "k": 60}})).unwrap(); + let was = label_run_style(&s, id, "Axes", "4").fill; + let mut p = params.clone(); + p["ids"] = json!([v.0]); + s.execute(cmd, &p).unwrap(); + let now = label_run_style(&s, id, "Axes", "4").fill; + assert_ne!(now, was, "{cmd} changes the fill"); + label_looks(&mut s, id); + assert_eq!(label_run_style(&s, id, "Axes", "0").fill, now, "{cmd}"); + }); +} + +/// Swap Fill and Stroke and Default Fill and Stroke on a label are kept for its kind. +#[test] +fn swap_and_default_fill_and_stroke_on_a_label_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.swap", &json!({"ids": [v.0]})).unwrap(); + let swapped = label_run_style(&s, id, "Axes", "4"); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((&st.fill, &st.stroke), (&swapped.fill, &swapped.stroke)); + assert_eq!(st.fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000")); + s.execute("paint.default", &json!({"ids": [text_id(&s, id, "Axes", "4").0]})).unwrap(); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((st.fill.color().map(|c| c.to_hex()).as_deref(), st.stroke.is_none()), (Some("#000000"), true)); +} + +/// On a pie with Legends in Wedges, a recolor of the whole graph leaves the legend labels to the graph's black or white. +#[test] +fn a_recolor_leaves_wedge_legend_labels_their_contrast() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nP1,1,2"); + s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); + s.execute("edit.colors.invert", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(spec(&s, id).label_styles.legend, None); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap(); + // The first series' black wedge is white now, so its label is black. + let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") }; + assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()).as_deref(), Some("#000000")); + // A recolor of one legend label gives the kind that label's fill. + s.execute("edit.colors.invert", &json!({"ids": [text_id(&s, id, "Legend", "a").0]})).unwrap(); + let kept = spec(&s, id).label_styles.legend.map(|st| st.fill); + assert_eq!(kept.and_then(|f| f.color()).map(|c| c.to_hex()).as_deref(), Some("#ffffff")); +} + #[test] fn pie_names_and_wedge_labels_keep_their_style() { let mut s = Session::new(); 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 3e1bc8b2ef..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()); } @@ -667,6 +711,16 @@ enum ClipTo { File(PathData), /// It goes into a new clip group of this path, inside the clip group that holds it. Nest(PathData), + /// Placement Options clip it to `placement` while its DCS file draws it through `file`: it is + /// clipped to `file` as [`ClipTo::File`] clips it, and the clip group holding it is clipped to + /// `placement` as [`ClipTo::Path`] clips an object. With no `file` (the new file lacks the + /// clipping path of the clip group holding it), that group stays as [`ClipTo::Off`] leaves it, + /// clipped to `placement`. + Both { file: Option, placement: PathData }, + /// Placement Options clip it to `placement` while its DCS file draws it through `file`, and the + /// clip group holding just it is not its old file's: that clip group's clipping path becomes + /// `placement`, and it goes into a new clip group of `file` inside it. + BothInside { file: PathData, placement: PathData }, /// The clipping path of the clip group holding just it goes (a DCS file's clipping path that /// its new file lacks): the group stays as a plain group of it, with its id, name, visibility /// and transparency. @@ -702,10 +756,10 @@ fn same_path(a: &PathData, b: &PathData) -> bool { } /// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with -/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file -/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path -/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip -/// groups get) goes. +/// `new_xf`: Placement Options' clip `clip` when they clip it, and the clipping path a DCS file +/// draws `img` through, the file's clip group inside the placement's. When neither clips it, a clip +/// group holding just it whose clipping path `held` is its old DCS file's (`old_clip`, in the old +/// pixels, under the nonzero rule its clip groups get) goes. fn clip_to( old_xf: Affine, old_clip: Option<&PathData>, @@ -714,16 +768,22 @@ fn clip_to( new_xf: Affine, held: Option<(&PathData, FillRule)>, ) -> ClipTo { - if let Some(clip) = clip { - return ClipTo::Path(clip_shape(clip)); - } // Whether the clip group holding just the image is the old DCS file's: a clipping path whose // fill rule was changed in the document is not. let file_held = match (old_clip, held) { (Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held), _ => false, }; - match img.clip.as_ref().map(|path| path.transformed(new_xf)) { + let file = img.clip.as_ref().map(|path| path.transformed(new_xf)); + if let Some(clip) = clip { + let placement = clip_shape(clip); + return match file { + Some(file) if held.is_some() && !file_held => ClipTo::BothInside { file, placement }, + file if file.is_some() || file_held => ClipTo::Both { file, placement }, + _ => ClipTo::Path(placement), + }; + } + match file { // A clipping mask made in the document stays, with the file's clip group inside it. Some(path) if held.is_some() && !file_held => ClipTo::Nest(path), Some(path) => ClipTo::File(path), @@ -747,17 +807,18 @@ 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; for l in d.layers.iter_mut() { - self.update(l, false); + // Nothing is picked in the layers themselves, so no layer is clipped. + let _ = self.update(l, false); } 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 { @@ -769,20 +830,30 @@ impl Nested<'_> { /// mask art). fn root(&mut self, n: &mut Arc) { if !(self.pick)(n) { - return self.update(n, true); + if let Some(placement) = self.update(n, true) { + *n = Arc::new(self.clip_group(Arc::clone(n), placement)); + } + return; } let (new, clip) = (self.make)(n, None); let mut new = Arc::new(new); - self.update(&mut new, true); + let _ = self.update(&mut new, true); *n = match clip { ClipTo::Path(path) | ClipTo::File(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)), + ClipTo::Both { file, placement } => self.clip_both(new, file, placement), + ClipTo::BothInside { file, placement } => self.clip_both(new, Some(file), placement), ClipTo::Keep | ClipTo::Off => new, }; } /// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays - /// the same node when none is there, and only the nodes on the way to one are copied. - fn update(&mut self, n: &mut Arc, nested: bool) { + /// the same node when none is there, and only the nodes on the way to one are copied. The + /// result is Placement Options' clip that `n` itself, now the clip group of a DCS file's + /// clipping path holding a picked object (or the plain group [`ClipTo::Off`] leaves), takes: + /// the clipping path of the clip group holding just `n` becomes it, else `n` goes into a new + /// clip group of it. + #[must_use] + fn update(&mut self, n: &mut Arc, nested: bool) -> Option { let mask = n.extras().mask.as_ref().and_then(|m| { let mut art = Arc::clone(&m.art); self.root(&mut art); @@ -794,6 +865,8 @@ impl Nested<'_> { // The clipping path and object of a plain group holding just the object, which becomes a // clip group again. let mut reclipped = None; + // Placement Options' clip for `n` itself. + let mut around = None; if let Some(children) = n.children() { // A clip group holding just the object: its clipping path changes. let clip_path = match (&n.kind, &children[..]) { @@ -804,9 +877,11 @@ impl Nested<'_> { for (i, c) in children.iter().enumerate() { if !(nested && (self.pick)(c)) { let mut new = Arc::clone(c); - self.update(&mut new, nested); - if !Arc::ptr_eq(&new, c) { - slots.push((i, new)); + match (self.update(&mut new, nested), clip_path) { + (None, _) if Arc::ptr_eq(&new, c) => {} + (None, _) => slots.push((i, new)), + (Some(placement), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, placement)), (1, new)]), + (Some(placement), _) => slots.push((i, Arc::new(self.clip_group(new, placement)))), } continue; } @@ -816,16 +891,10 @@ impl Nested<'_> { }); let (new, clip) = (self.make)(c, held); let mut new = Arc::new(new); - self.update(&mut new, true); + let _ = self.update(&mut new, true); match (clip, clip_path) { (ClipTo::Keep, _) => slots.push((i, new)), - (ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => { - let mut path = Node::clone(path); - if let NodeKind::Path { path: p, .. } = &mut path.kind { - *p = clip; - } - slots.extend([(0, Arc::new(path)), (1, new)]); - } + (ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, clip)), (1, new)]), (ClipTo::File(clip), None) if plain => reclipped = Some((clip, new)), (ClipTo::Path(clip) | ClipTo::File(clip) | ClipTo::Nest(clip), _) => { let group = self.clip_group(new, clip); @@ -833,11 +902,30 @@ impl Nested<'_> { } (ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new), (ClipTo::Off, _) => slots.push((i, new)), + // The file's clip group stays `n`, and the placement's clip goes around it. + (ClipTo::Both { file: Some(file), placement }, Some(path)) if i == 1 => { + slots.extend([(0, with_clip(path, file)), (1, new)]); + around = Some(placement); + } + (ClipTo::Both { file: Some(file), placement }, None) if plain => { + reclipped = Some((file, new)); + around = Some(placement); + } + (ClipTo::Both { file: None, placement }, Some(_)) if i == 1 => { + declipped = Some(new); + around = Some(placement); + } + (ClipTo::BothInside { file, placement }, Some(path)) if i == 1 => { + let group = self.clip_group(new, file); + slots.extend([(0, with_clip(path, placement)), (1, Arc::new(group))]); + } + (ClipTo::Both { file, placement }, _) => slots.push((i, self.clip_both(new, file, placement))), + (ClipTo::BothInside { file, placement }, _) => slots.push((i, self.clip_both(new, Some(file), placement))), } } } if mask.is_none() && slots.is_empty() && declipped.is_none() && reclipped.is_none() { - return; + return None; } let reclipped = reclipped.map(|(clip, object)| (Arc::new(self.clipping_path(clip)), object)); let n = Arc::make_mut(n); @@ -849,14 +937,14 @@ impl Nested<'_> { if let NodeKind::Group { children, clip } = &mut n.kind { (*children, *clip) = (vec![path, object], true); } - return; + return around; } // A clip group that stays as a plain group of just the object. if let Some(object) = declipped { if let NodeKind::Group { children, clip } = &mut n.kind { (*children, *clip) = (vec![object], false); } - return; + return around; } if let Some(children) = n.children_mut() { for (i, c) in slots { @@ -865,6 +953,17 @@ impl Nested<'_> { } } } + around + } + + /// A clip group of `placement` holding `object`, inside a clip group of `file` when there is a + /// `file` ([`ClipTo::Both`] for an object no clip group holds yet). + fn clip_both(&mut self, object: Arc, file: Option, placement: PathData) -> Arc { + let object = match file { + Some(file) => Arc::new(self.clip_group(object, file)), + None => object, + }; + Arc::new(self.clip_group(object, placement)) } /// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers. @@ -1035,6 +1134,15 @@ fn clip_file_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { Ok(()) } +/// Clipping path `path` (a node) with its path changed to `clip`. +fn with_clip(path: &Node, clip: PathData) -> Arc { + let mut path = Node::clone(path); + if let NodeKind::Path { path: p, .. } = &mut path.kind { + *p = clip; + } + Arc::new(path) +} + /// A clipping path `id` of `path`, unpainted. fn clipping_path(id: NodeId, path: PathData) -> Node { let none = vectorcraft_color::Paint::None; @@ -1130,6 +1238,19 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio ClipTo::File(path) => clip_file_to(d, id, path), ClipTo::Nest(path) => new_clip_group(d, id, path), ClipTo::Off => unclip(d, id), + ClipTo::Both { file, placement } => { + match file { + Some(file) => clip_file_to(d, id, file)?, + None => unclip(d, id)?, + } + // The file's clip group (or the plain group left of it) takes the placement's clip. + let group = d.parent_of(id).ok_or(EngineError::NoNode(id))?; + clip_image_to(d, group, placement) + } + ClipTo::BothInside { file, placement } => { + clip_image_to(d, id, placement)?; + new_clip_group(d, id, file) + } }) } @@ -1229,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 @@ -1381,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 @@ -1392,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) @@ -1449,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()?; @@ -1458,23 +1585,29 @@ pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option); @@ -1629,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() { @@ -1756,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, @@ -1788,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 @@ -1799,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. @@ -1835,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) { @@ -1886,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(); @@ -1998,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))), } } @@ -2028,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))), } } @@ -2141,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)) } } @@ -2196,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) { @@ -2218,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 })); } @@ -2233,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, @@ -2285,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); @@ -2318,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))); } @@ -2345,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 })) } @@ -2403,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)); @@ -2430,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]); @@ -2453,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() { @@ -2474,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) })); } @@ -2484,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/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index e3909c5935..b30959de99 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -149,9 +149,22 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { }; let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0))); let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0"); - let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?; + // Saved steps ([`Session::replay`]) can hold values a direct call refuses, recorded before these + // checks: a negative radius is 0, a whole side count of 0 or more is brought into 3 to 1000, and + // a fractional or negative one is ignored. A whole number written as 5.0 counts as 5, as in a + // direct call. + let replaying = s.replaying(); + let radius = if replaying { + number("radius", |_| true, "a number")?.map(|r| r.max(0.0)) + } else { + number("radius", |r| r >= 0.0, "a radius of 0 or more")? + }; // A polygon's side count: a whole number from 3 to 1000. - let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64); + let sides = if replaying { + number("sides", |_| true, "a number")?.filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(1000.0) as u64) + } else { + number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64) + }; // An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them). let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?); let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 75d5852ea2..ac3c780c11 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -472,7 +472,7 @@ impl Session { pub fn transparency_info(&self) -> TransparencyInfo { match (self.active(), transparency_targets(self, &Value::Null)) { (Some(st), Ok(ids)) => { - let points = super::paint::picked_mesh_points_of(self, &ids); + let points = self.mesh_points_picked(); TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points) } _ => TransparencyInfo::default(), @@ -483,7 +483,8 @@ impl Session { fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let ids = transparency_targets(s, p)?; - let points = super::paint::picked_mesh_points_of(s, &ids); + // The points `transparency.set` with the same `ids` (or none) fades. + let points = if p.get("ids").is_none() && p.get("id").is_none() { s.mesh_points_picked() } else { super::paint::picked_mesh_points_of(s, &ids) }; let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points); Ok(json!({ "ids": i.ids.iter().map(|id| id.0).collect::>(), 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/paint.rs b/crates/engine/src/cmd/paint.rs index 3ec28e7ce7..4f822da5db 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Swap Fill and Stroke", [], Some("Shift+X"), - "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)", + "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight, and on a graph label the swapped paints of its characters for every label of its kind (exceptions: docs/mcp.md)", has_doc, swap ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and on a graph label the black fill and no stroke of its characters for every label of its kind (exceptions: docs/mcp.md)", has_doc, default_paint ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md); no undo step when no color changes → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -154,7 +154,7 @@ pub fn proxy_specs() -> Vec { "Fill and Stroke", [], None, - "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}", + "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}", always, proxies ), @@ -179,9 +179,13 @@ impl Session { /// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the /// first selected object's own stack) for the proxy of its kind, else the first selected /// object's (a group's first painted object's; type: its first run's), else the defaults for - /// new art. + /// new art. When the object whose fill the proxy shows is a gradient mesh with points picked + /// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid + /// fill color changes. pub fn proxy_paints(&self) -> (Paint, Paint) { + let points = self.fill_mesh_points(); let show = |stroke: bool| match self.proxy_source(stroke) { + Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c), Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), @@ -273,6 +277,13 @@ impl DocState { /// each selected object's own stack is compared. One walk of the document, so callers that ask /// every frame cache it by revision. pub fn proxy_mixed(&self, item: Option) -> (bool, bool) { + self.proxy_mixed_with(item, &[]) + } + + /// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors + /// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of + /// its picked points instead of its fill. + fn proxy_mixed_with(&self, item: Option, points: &[(NodeId, Vec)]) -> (bool, bool) { if self.selection.is_empty() { return (false, false); } @@ -291,7 +302,10 @@ impl DocState { let differ = |stroke: bool| { let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke)); let set = if item.is_some() { &selected } else { &nodes }; - let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item)); + let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) { + c if c.is_empty() => vec![proxy_paint(n, stroke, item)], + c => c.into_iter().map(Paint::solid).collect(), + }); let Some(p0) = paints.next() else { return false }; paints.any(|p| !same_in_proxy(&p0, &p)) }; @@ -313,8 +327,9 @@ impl DocState { impl Session { /// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item. + /// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors. pub fn proxy_mixed(&self) -> (bool, bool) { - self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item())) + self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points())) } } @@ -539,8 +554,8 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; - // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill - // recolours just those points of their meshes. + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid + // fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`). let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; // Without `item`, unless the Appearance panel's active fill item takes the paint. let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top { @@ -871,6 +886,55 @@ pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option)]) -> Vec { + match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) { + (NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(), + _ => vec![], + } +} + +impl Session { + /// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or + /// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are + /// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's + /// active item is a fill row or an object of a symbol instance is selected, and a mesh with + /// points picked counts even when other objects are selected with it. + pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec)> { + let Some(st) = self.active() else { return vec![] }; + // Only gradient meshes have points to pick: a selection with no mesh and no container that + // could hold one skips reading the tool's options. + let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container()); + if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) { + return vec![]; + } + let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] }; + if item != ItemTarget::Top { + return vec![]; + } + let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] }; + picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected)) + } + + /// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set` + /// without `ids` or `item`) fades instead of their objects: `None` unless every target is a + /// gradient mesh with points picked, and while the Appearance panel's active item takes the + /// opacity (outside opacity mask editing). + pub fn mesh_points_picked(&self) -> Option)>> { + let st = self.active()?; + let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?; + // Only gradient meshes have points to pick: other targets skip reading the tool's options. + if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) { + return None; + } + if st.doc.mask_edit.is_none() && self.appearance_item().is_some() { + return None; + } + picked_mesh_points_of(self, &ids) + } +} + /// A type run's stroke; a painted stroke on a run without one gets 1 pt. pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) { if !paint.is_none() { @@ -925,7 +989,8 @@ fn swap(s: &mut Session, p: &Value) -> Result { } // The swap writes the paint of the topmost fill and stroke, not their opacity. let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false); - Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) + let labels = super::graph::capture_label_changes(&before, d, &ids, false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels)) })?; } if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 { @@ -954,7 +1019,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { } } let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false); - Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) + let labels = super::graph::capture_label_changes(&before, d, &ids, false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels)) })?; } super::newart::reset(s); @@ -1084,8 +1150,8 @@ fn transparency(s: &mut Session, p: &Value) -> Result { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); - // The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity - // themselves, so only the mesh around them fades (#4206, #4170, #4208). + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the + // opacity themselves, so only the mesh around them fades (#4206, #4170, #4208). if matches!(item, ItemTarget::Top) && let Some(o) = q.get("opacity").and_then(Value::as_f64) && let Some(points) = picked_mesh_points_of(s, &ids) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index c6731118d7..2383d05a3a 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -588,6 +588,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "warichuAlign", "akiLeft", "akiRight", + "tsume", "standardVerticalRoman", "noBreak", "digits", @@ -632,6 +633,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let aki_left = super::textedit::aki_param(p, "akiLeft", C)?; let aki_right = super::textedit::aki_param(p, "akiRight", C)?; + let tsume = super::textedit::tsume_param(p, C)?; let warichu_lines = super::textedit::warichu_lines_param(p, C)?; let warichu_gap = super::textedit::warichu_number_param(p, "warichuGap", C)?; let warichu_scale = super::textedit::warichu_number_param(p, "warichuScale", C)?; @@ -816,6 +818,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = aki_right { st.aki_right = v; } + if let Some(v) = tsume { + st.tsume = v; + } if let Some(v) = flag("standardVerticalRoman") { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 9d7b42cca2..2ad2b70049 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Reverse Path Direction", ["Object", "Path"], None, - "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", + "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}", has_selected_path, reverse ), @@ -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)?; @@ -462,22 +462,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result { let mut changed = 0; for id in &ids { let path = path_mut(d, *id)?; - if let Some(si) = subpath { - if let Some(sp) = path.subpaths.get_mut(si) { + let flip: Vec = match subpath { + Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(), + None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(), + }; + if !flip.contains(&true) { + continue; + } + // The stroke's width profile and pen recordings turn with the path, read before it turns. + if let Some(n) = d.node_mut(*id) { + vectorcraft_doc::blend::reverse_stroke_records(n, &flip); + } + let path = path_mut(d, *id)?; + for (sp, f) in path.subpaths.iter_mut().zip(flip) { + if f { sp.reverse(); - changed += 1; - } - } else { - let flip: Vec = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(); - if flip.contains(&true) { - for (sp, f) in path.subpaths.iter_mut().zip(flip) { - if f { - sp.reverse(); - } - } - changed += 1; } } + changed += 1; } Ok(changed) })?; 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..dc95f925b6 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -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/place/text.rs b/crates/engine/src/cmd/place/text.rs index 25f5f36dbd..7d8afc92d4 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -3,6 +3,7 @@ use serde_json::Value; +use super::super::utf16_order_without_mark; use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param}; /// Text files as `file.place` reports them (not a format `document.open` reads). @@ -76,8 +77,10 @@ pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result Ok(clean(&decode(bytes, o), o)) } -/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are -/// read as the platform's 8-bit set), or the platform's 8-bit character set. +/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark, or without one when the +/// file has an even length, reads as valid UTF-16 with no NUL character in the byte order +/// [`utf16_order_without_mark`] gives; bytes that aren't UTF-8 are read as the platform's 8-bit +/// set), or the platform's 8-bit character set. fn decode(bytes: &[u8], o: TextOptions) -> String { let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() }; if o.ansi { @@ -91,7 +94,17 @@ fn decode(bytes: &[u8], o: TextOptions) -> String { [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), [0xFF, 0xFE, rest @ ..] => utf16(rest, true), [0xFE, 0xFF, rest @ ..] => utf16(rest, false), - _ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string), + _ => { + let without_mark = (bytes.len().is_multiple_of(2) && bytes.contains(&0)) + .then(|| utf16_order_without_mark(bytes)) + .flatten() + .and_then(|le| { + let units = bytes.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).collect::>().ok() + }) + .filter(|text| !text.contains('\0')); + without_mark.unwrap_or_else(|| std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string)) + } } } 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/recolor.rs b/crates/engine/src/cmd/recolor.rs index 07f1f4c46b..11c4744f47 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c5e5ba2a5c..14282c5112 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters the stroke options they take → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Stroke Weight Up", [], Some("Cmd+Alt+."), - "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}", + "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke, and on a graph label the new weight of its characters' stroke or its stroke row for every label of its kind (docs/mcp.md) → {objects}", has_doc, |s, p| stroke_step(s, p, true) ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index bb45bf0fca..e58f109d20 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "New Graphic Style", ["Window", "Graphic Styles"], None, - "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}", + "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object, unless it is the art of a graph design in a series. Names are made unique → {name}", has_doc, style_new ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. The art of a graph design in a series keeps its look and is unlinked, as the source or as a linked object. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}", has_doc, style_redefine ), @@ -392,8 +392,10 @@ fn style_new(s: &mut Session, p: &Value) -> Result { None => d.new_graphic_style_name(), }; let g = GraphicStyle { id: d.next_graphic_style_id(), ..GraphicStyle::of(name.clone(), captured(n), n) }; + // The art of a graph design in a series is not linked: the graph draws it again from the design. + let link = (!super::graph::is_design_art(d, src)).then_some(g.id); s.edit("New Graphic Style", |d, _| { - set_link(d, &[src], Some(g.id)); + set_link(d, &[src], link); d.graphic_styles.push(g); Ok(()) })?; @@ -491,10 +493,12 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| { let id = d.graphic_style_id(i); let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); - // Objects still linked follow the new definition; edited ones are unlinked (they keep - // their look), and the source keeps its own. + // Objects still linked follow the new definition; edited ones are unlinked (they keep their look), and the + // source keeps its own. The art of a graph design in a series is unlinked and keeps its look too, as the + // source or as a linked object: the graph draws it again from the design. let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src); - let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); + let (follow, edited): (Vec, Vec) = + linked.into_iter().partition(|nid| !super::graph::is_design_art(d, *nid) && d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); // A linked graph series group passes the style to the paths directly inside it, so the graph stores it, and // goes back to the empty appearance the graph draws it with. The source and the edited paths keep their look. @@ -509,7 +513,8 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { } super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true); super::graph::capture_label_rows(d, &follow_marks); - set_link(d, &[src], Some(id)); + let link = (!super::graph::is_design_art(d, src)).then_some(id); + set_link(d, &[src], link); Ok(()) })?; Ok(json!({ "name": name })) 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/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 98b46a5d1a..7ddc6e4bb2 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, LazyLock}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Symbol, SymbolNineSlice}; -use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Rect, SubPath, shapes}; use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_natural_size}; use super::clipboard::Clipboard; @@ -160,6 +160,7 @@ pub const BUILTIN: &[BuiltinSymbolLibrary] = &[ BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers }, BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons }, BuiltinSymbolLibrary { id: "skill-icons", name: "Skill Icons", make: skill_icons }, + BuiltinSymbolLibrary { id: "weather-icons", name: "Weather Icons", make: weather_icons }, ]; /// Built-in library `id`, made once. @@ -358,6 +359,140 @@ fn skill_icons() -> SymbolLibrary { SymbolLibrary { name: "Skill Icons".into(), doc } } +/// Weather Icons: sun, moon, cloud, rain, thunder and snow for weather/status art (original, no 9-slice). +fn weather_icons() -> SymbolLibrary { + // 1. Sun: Central circular disk with radial rays. + let mut sun_subpaths = Vec::new(); + sun_subpaths.extend(shapes::ellipse(Rect::new(-8.5, -8.5, 8.5, 8.5)).subpaths); + for i in 0..8 { + let angle = (i as f64) * std::f64::consts::PI / 4.0; + let (sin, cos) = angle.sin_cos(); + let r_in = 11.5; + let r_out = 18.5; + let w = 1.5; + let p1 = Point::new(r_in * cos - w * sin, r_in * sin + w * cos); + let tip = Point::new(r_out * cos, r_out * sin); + let p2 = Point::new(r_in * cos + w * sin, r_in * sin - w * cos); + sun_subpaths.push(SubPath::polyline(&[p1, tip, p2], true)); + } + let sun = PathData::new(sun_subpaths); + + // 2. Moon: Crescent moon shape. + let moon_anchors = vec![ + Anchor { p: Point::new(3.0, -17.0), h_in: Point::new(-2.0, -10.0), h_out: Point::new(-8.0, -17.0), kind: AnchorKind::Corner }, + Anchor { p: Point::new(-16.0, 0.0), h_in: Point::new(-16.0, -9.0), h_out: Point::new(-16.0, 9.0), kind: AnchorKind::Smooth }, + Anchor { p: Point::new(5.0, 16.0), h_in: Point::new(-6.0, 16.0), h_out: Point::new(0.0, 8.0), kind: AnchorKind::Corner }, + Anchor { p: Point::new(-4.0, -1.0), h_in: Point::new(-4.0, 6.0), h_out: Point::new(-4.0, -8.0), kind: AnchorKind::Smooth }, + ]; + let moon = PathData::single(SubPath { anchors: moon_anchors, closed: true }); + + // Helper for cloud contour + let cloud_subpath = |offset_y: f64, scale: f64| -> SubPath { + let mut pts = Vec::new(); + let base_y = 8.0 * scale + offset_y; + pts.push(Point::new(9.0 * scale, base_y)); + pts.push(Point::new(-9.0 * scale, base_y)); + + let c1 = Point::new(-9.0 * scale, 2.0 * scale + offset_y); + let r1 = 6.0 * scale; + let steps_lobe = 8; + for i in 1..=steps_lobe { + let a = std::f64::consts::FRAC_PI_2 + (i as f64) * std::f64::consts::PI / (steps_lobe as f64); + pts.push(Point::new(c1.x + r1 * a.cos(), c1.y + r1 * a.sin())); + } + + let c2 = Point::new(0.0, -3.0 * scale + offset_y); + let r2 = 9.0 * scale; + let a_start = -2.973; + let a_end = -0.169; + let steps_top = 10; + for i in 1..=steps_top { + let a = a_start + (i as f64) * (a_end - a_start) / (steps_top as f64); + pts.push(Point::new(c2.x + r2 * a.cos(), c2.y + r2 * a.sin())); + } + + let c3 = Point::new(9.0 * scale, 2.0 * scale + offset_y); + let r3 = 6.0 * scale; + for i in 1..steps_lobe { + let a = -std::f64::consts::FRAC_PI_2 + (i as f64) * std::f64::consts::PI / (steps_lobe as f64); + pts.push(Point::new(c3.x + r3 * a.cos(), c3.y + r3 * a.sin())); + } + + SubPath::polyline(&pts, true) + }; + + // 3. Cloud: Merged circular lobes with a flat base. + let cloud = PathData::single(cloud_subpath(0.0, 1.0)); + + // 4. Rain: Cloud outline with angled falling droplet/dash lines. + let mut rain_subpaths = vec![cloud_subpath(-4.0, 0.9)]; + let drops = [(-6.0, 6.0), (0.0, 8.0), (6.0, 6.0)]; + for &(x0, y0) in &drops { + let (dx, dy) = (-2.0, 6.0); + let w = 1.0; + let d = [Point::new(x0 + w, y0), Point::new(x0 + dx + w, y0 + dy), Point::new(x0 + dx - w, y0 + dy), Point::new(x0 - w, y0)]; + rain_subpaths.push(SubPath::polyline(&d, true)); + } + let rain = PathData::new(rain_subpaths); + + // 5. Thunder: Angular zigzag lightning bolt polygon. + let bolt = [(3.0, -18.0), (-8.0, -1.0), (-1.0, -1.0), (-5.0, 18.0), (8.0, 1.0), (1.0, 1.0)]; + let bolt_pts: Vec = bolt.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let thunder = PathData::single(SubPath::polyline(&bolt_pts, true)); + + // 6. Snow: Symmetrical snowflake with hexagonal cross and branching ticks. + let mut snow_pts = Vec::new(); + let r_hub = 3.5; + for k in 0..6 { + let angle = (k as f64) * std::f64::consts::PI / 3.0; + let (u_sin, u_cos) = angle.sin_cos(); + let u = Point::new(u_cos, u_sin); + let v = Point::new(-u_sin, u_cos); + let to_pt = |du: f64, dv: f64| Point::new(du * u.x + dv * v.x, du * u.y + dv * v.y); + + let valley_angle = angle - std::f64::consts::PI / 6.0; + snow_pts.push(Point::new(r_hub * valley_angle.cos(), r_hub * valley_angle.sin())); + + snow_pts.push(to_pt(5.0, -1.0)); + snow_pts.push(to_pt(8.5, -1.0)); + snow_pts.push(to_pt(10.0, -3.0)); + snow_pts.push(to_pt(11.5, -1.0)); + snow_pts.push(to_pt(15.5, -1.0)); + snow_pts.push(to_pt(15.5, -2.5)); + + snow_pts.push(to_pt(18.0, 0.0)); + + snow_pts.push(to_pt(15.5, 2.5)); + snow_pts.push(to_pt(15.5, 1.0)); + snow_pts.push(to_pt(11.5, 1.0)); + snow_pts.push(to_pt(10.0, 3.0)); + snow_pts.push(to_pt(8.5, 1.0)); + snow_pts.push(to_pt(5.0, 1.0)); + } + let snow = PathData::single(SubPath::polyline(&snow_pts, true)); + + let icons = [ + ("Sun", sun, "#f39c12"), + ("Moon", moon, "#5c6bc0"), + ("Cloud", cloud, "#78909c"), + ("Rain", rain, "#3498db"), + ("Thunder", thunder, "#f1c40f"), + ("Snow", snow, "#29b6f6"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in icons { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + let edge = fill.lerp(&Color::BLACK, 0.35); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Weather Icons".into(); + SymbolLibrary { name: "Weather Icons".into(), doc } +} + /// An instance of symbol `name` (what the resource gathering follows). fn instance(name: &str) -> Node { Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 0fd7c7d97d..9ed7f3252b 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -468,6 +468,8 @@ pub(crate) struct CharChange { /// Insert Aki Left and Right, in 1/1000 em. aki_left: Option, aki_right: Option, + /// Tsume, in percent. + tsume: Option, standard_vertical_roman: Option, no_break: Option, digits: Option, @@ -545,6 +547,16 @@ pub(crate) fn aki_param(p: &Value, key: &str, cmd: &str) -> Result> if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } } +/// `tsume` (Tsume): a percentage from 0 to [`vectorcraft_doc::text::MAX_TSUME`]. Any other value is +/// an error. +pub(crate) fn tsume_param(p: &Value, cmd: &str) -> Result> { + let key = "tsume"; + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?; + let (lo, hi) = (0.0, vectorcraft_doc::text::MAX_TSUME); + if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + /// `digits: "default"|"arabic"|"hindi"|"farsi"|"devanagari"|…` ([`Digits::id`]) → the numeral /// system the digits are drawn in. /// @@ -824,6 +836,7 @@ impl CharChange { warichu_align: warichu_align_param(p, cmd)?, aki_left: aki_param(p, "akiLeft", cmd)?, aki_right: aki_param(p, "akiRight", cmd)?, + tsume: tsume_param(p, cmd)?, standard_vertical_roman: flag("standardVerticalRoman"), no_break: flag("noBreak"), digits: digits_param(p, cmd)?, @@ -888,6 +901,7 @@ impl CharChange { && self.warichu_align.is_none() && self.aki_left.is_none() && self.aki_right.is_none() + && self.tsume.is_none() && self.standard_vertical_roman.is_none() && self.no_break.is_none() && self.digits.is_none() @@ -976,6 +990,9 @@ impl CharChange { if let Some(v) = self.aki_right { st.aki_right = v; } + if let Some(v) = self.tsume { + st.tsume = v; + } if let Some(v) = self.standard_vertical_roman { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index ea1a7a22bd..17d453abf4 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -222,6 +222,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("warichuAlign", json!(v.warichu_align)), ("akiLeft", json!(v.aki_left)), ("akiRight", json!(v.aki_right)), + ("tsume", json!(v.tsume)), ("standardVerticalRoman", json!(v.standard_vertical_roman)), ("noBreak", json!(v.no_break)), ("digits", json!(v.digits)), @@ -578,6 +579,10 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}"))); } } + let (key, lo, hi) = ("tsume", 0.0, vectorcraft_doc::text::MAX_TSUME); + if changed(key) && !(lo..=hi).contains(&st.tsume) { + return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}"))); + } let lines = vectorcraft_doc::text::WARICHU_LINES; if changed("warichuLines") && !lines.contains(&st.warichu_lines) { return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end()))); @@ -1075,12 +1080,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result { /// The styles of `kind` in `src` that load, with their source names so that art in the file keeps /// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, -/// scales, leading, shifts, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose +/// scales, leading, shifts, Insert Aki, Tsume, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose /// indents, spacing, Hyphenation or Justification options or tab stops are out of the range /// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in /// `src` of those skipped) fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { - const RANGED: [&str; 21] = [ + const RANGED: [&str; 22] = [ "size", "h_scale", "v_scale", @@ -1088,6 +1093,7 @@ fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { "baseline_shift", "akiLeft", "akiRight", + "tsume", "tcyUpDown", "tcyLeftRight", "warichuLines", diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 3e0727ce4a..c8da4b578c 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -397,6 +397,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() { json!({"tcyLeftRight": -1296.1}), json!({"akiLeft": -0.1}), json!({"akiRight": 1000.1}), + json!({"tsume": -0.1}), + json!({"tsume": 100.1}), ] { let key = attrs.as_object().unwrap().keys().next().unwrap(); for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] { @@ -419,7 +421,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() { "h_scale": 1.0, "v_scale": 10000.0, "baseline_shift": -1296.0, "tcyUpDown": 1296.0, "tcyLeftRight": -1296.0, - "akiLeft": 0.0, "akiRight": 1000.0 + "akiLeft": 0.0, "akiRight": 1000.0, + "tsume": 100.0 }}), ) .unwrap(); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 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 db1712a7f8..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); @@ -1821,6 +1832,61 @@ fn insert_aki_is_a_character_attribute_set_one_side_at_a_time() { assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call"); } +/// Tsume (Character panel, East Asian options): a character attribute in percent, 0 by default +/// and saved only when not 0. Both commands set it in one undo step, a call that gives the value +/// the text already has records none, and a value that is not a number or lies outside 0 to 100 is +/// refused, naming the key, before anything changes; a character style captured from the +/// characters carries it. +#[test] +fn tsume_is_a_character_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let tsume = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.tsume)).collect::>(); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert!(saved.get("tsume").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"tsume": 50})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"tsume": 50})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step without a change"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 0.0)], "one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]); + + let paren = "(".len(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": paren, "tsume": 100})).unwrap(); + assert_eq!(tsume(&s), [("(".to_string(), 100.0), ("日本)".to_string(), 50.0)]); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved["tsume"], json!(100.0)); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.tsume)).collect(); + assert_eq!(reopened, tsume(&s)); + let attrs = super::super::textstyles::char_attrs(&runs(&s)[0].style); + assert_eq!(attrs["tsume"], json!(100.0)); + + let before = tsume(&s); + let undo = s.doc().unwrap().history.undo.len(); + for (cmd, p, want) in [ + ("text.setFormat", json!({"tsume": "50"}), "`tsume` must be a number"), + ("text.setFormat", json!({"tsume": -1}), "`tsume` must be from 0 to 100"), + ("text.setRangeStyle", json!({"id": id, "tsume": true}), "`tsume` must be a number"), + ("text.setRangeStyle", json!({"id": id, "start": 0, "end": paren, "tsume": 100.5}), "`tsume` must be from 0 to 100"), + ] { + let e = s.execute(cmd, &p).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}"); + } + assert_eq!(tsume(&s), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call"); +} + /// Standard Vertical Roman Alignment (Character panel menu, East Asian options): a character /// attribute, off by default and saved only when on. Documents written before it read as off; a /// character style captured from the characters carries it. @@ -2276,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/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 9acd31e664..5f8d6605b3 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -26,6 +26,7 @@ use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids mod csv; mod library; +pub use csv::utf16_order_without_mark; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; diff --git a/crates/engine/src/cmd/variablecmds/csv.rs b/crates/engine/src/cmd/variablecmds/csv.rs index 38dd42d922..be9db3b841 100644 --- a/crates/engine/src/cmd/variablecmds/csv.rs +++ b/crates/engine/src/cmd/variablecmds/csv.rs @@ -59,25 +59,35 @@ fn utf16(bytes: &[u8], le: bool) -> Option { char::decode_utf16(units).collect::>().ok().filter(|_| odd.is_empty()) } -/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of -/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with -/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is -/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or -/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either -/// order has no data rows, and is read little-endian so that the import reports that. The text -/// must be valid UTF-16 in that order and hold no NUL character. -fn utf16_without_mark(bytes: &[u8]) -> Option { - let (units, odd) = bytes.as_chunks::<2>(); - if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() { +/// The byte order of `bytes`, which hold NUL bytes and no byte-order mark, read as UTF-16: +/// `Some(true)` for little-endian, or `None` when they don't read as UTF-16. At least half of the +/// two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with fewer, +/// such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is the one +/// that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or U+0D00, +/// so a file that reads one in both orders is refused. A file that reads none in either order (one +/// line) is big-endian when more of its units start with a NUL byte than end with one, as +/// characters from U+0001 to U+00FF do in that order, and little-endian otherwise. An odd last +/// byte is left out of the count. +pub fn utf16_order_without_mark(bytes: &[u8]) -> Option { + let units = bytes.as_chunks::<2>().0; + if units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() { return None; } let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D); let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c))); let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c))); - if lines_le && lines_be { - return None; + match (lines_le, lines_be) { + (true, true) => None, + (false, false) => Some(units.iter().filter(|c| c[0] == 0).count() <= units.iter().filter(|c| c[1] == 0).count()), + (_, lines_be) => Some(!lines_be), } - utf16(bytes, !lines_be).filter(|text| !text.contains('\0')) +} + +/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16 in the byte order +/// [`utf16_order_without_mark`] gives, or `None`. The file must have an even length, and the text +/// must be valid UTF-16 in that order and hold no NUL character. +fn utf16_without_mark(bytes: &[u8]) -> Option { + utf16(bytes, utf16_order_without_mark(bytes)?).filter(|text| !text.contains('\0')) } /// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted. 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.rs b/crates/engine/src/tests.rs index 2f849901a4..57445a36be 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -241,6 +241,25 @@ pub(crate) fn key_guide(s: &Session) -> Option { st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied()) } +/// Runs the unit test `path` (`tests::newdoc::...`) again in a child process of the test binary +/// → whether this is that child. For a test that changes process-wide state the tests running +/// beside it read, such as the active color settings. +pub(crate) fn in_own_process(path: &str) -> bool { + const CHILD: &str = "VECTORCRAFT_TEST_OWN_PROCESS"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let output = + std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", path, "--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;"), + "{path} failed in its own process:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + false +} + #[test] fn create_and_undo_redo() { let mut s = session(); 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/attributes.rs b/crates/engine/src/tests/attributes.rs index d3edf2615b..8ddfc352a1 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -163,6 +163,61 @@ fn reverse_path_direction_on_and_off_set_the_winding() { assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse"); } +#[test] +fn reverse_path_direction_turns_the_width_profile_and_pen_records_with_the_path() { + use vectorcraft_doc::{AngleProfile, PressureProfile, WidthProfile}; + let mut s = session(); + let open = + NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 50}, {"x": 40, "y": 50}, {"x": 100, "y": 50}]}))["id"].as_u64().unwrap()); + let closed = rect(&mut s, 10.0); + let profile = WidthProfile { points: vec![(0.0, 0.2, 0.6), (0.3, 1.0, 0.4), (1.0, 0.5, 0.9)] }; + let pressure = PressureProfile { points: vec![(0.0, 0.1), (0.25, 0.9), (1.0, 0.4)] }; + let bearing = AngleProfile { points: vec![(0.0, 10.0), (0.6, 80.0), (1.0, 170.0)] }; + s.edit("set", |d, _| { + for id in [open, closed] { + let st = d.node_mut(id).unwrap().appearance.stroke_mut().unwrap(); + st.profile = Some(profile.clone()); + st.pressure = Some(pressure.clone()); + st.tilt = Some(pressure.clone()); + st.bearing = Some(bearing.clone()); + st.rotation = Some(bearing.clone()); + } + Ok(()) + }) + .unwrap(); + // (anchor point, its fraction of the path's length) for each anchor. + let along = |s: &Session, id: NodeId| { + let sp = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); + let pos = sp.anchor_arc_positions(); + let total = *pos.last().unwrap(); + sp.anchors.iter().zip(pos).map(|(a, at)| (a.p, at / total)).collect::>() + }; + let before = [along(&s, open), along(&s, closed)]; + run(&mut s, "select.set", json!({"ids": [open.0, closed.0]})); + assert_eq!(run(&mut s, "path.reverse", json!({}))["changed"], 2); + for ((id, before), closed) in [open, closed].into_iter().zip(before).zip([false, true]) { + let after = along(&s, id); + let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone(); + // Each anchor keeps the widths it had (its sides swapped, as the path runs the other way) + // and what the pen recorded there. (A closed path's start is where its profile steps.) A + // closed path now starts part way along a span of the width profile, which is resampled + // there, so its smooth curve moves a little. + let near = if closed { 0.02 } else { 1e-6 }; + for (p, t) in before.iter().filter(|(_, t)| !closed || *t > 0.0) { + let u = after.iter().find(|(q, _)| q.distance(*p) < 1e-9).unwrap().1; + let (l, r) = profile.at(*t); + let (l2, r2) = st.profile.as_ref().unwrap().at(u); + assert!((l - r2).abs() < near && (r - l2).abs() < near, "{id:?} at {p:?}: {:?} then {:?}", (l, r), (l2, r2)); + for got in [&st.pressure, &st.tilt] { + assert!((pressure.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} pressure at {p:?}"); + } + for got in [&st.bearing, &st.rotation] { + assert!((bearing.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} bearing at {p:?}"); + } + } + } +} + #[test] fn attributes_survive_a_native_round_trip() { let mut s = session(); 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 1c3ad5d8b4..01eb7f7132 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2947,6 +2947,192 @@ fn live_paint_release_and_expand() { assert!((area(&s, a) - 10000.0).abs() < 1.0); } +/// Set the appearance rows of the face of Live Paint group `g` at `face` (or the edge nearest +/// `edge`) to `items`, and give it `opacity`; returns that child's id. +fn set_live_part(s: &mut Session, g: NodeId, face: Option<(f64, f64)>, edge: Option<(f64, f64)>, items: Vec, opacity: f32) -> NodeId { + use vectorcraft_tools::builder::{edge_near, face_at}; + let n = node(s, g); + let part = match (face, edge) { + (Some((x, y)), _) => face_at(&n, Point::new(x, y)), + (_, Some((x, y))) => edge_near(&n, Point::new(x, y), 0.5), + _ => None, + }; + let id = part.unwrap().id; + s.edit("t", |d, _| { + let c = d.node_mut(id).unwrap(); + c.appearance.items = items; + c.opacity = opacity; + Ok(()) + }) + .unwrap(); + id +} + +/// Expand keeps a face or edge when a fill or stroke row draws it, whatever row is on top: a +/// painted lower row under an unpainted top row is kept, and a part whose only painted row is +/// hidden, transparent or 0 pt wide, or whose own opacity is 0, is dropped. +#[test] +fn live_paint_expand_keeps_the_faces_and_edges_that_draw() { + use vectorcraft_doc::{FillLayer, StrokeLayer}; + let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p)); + let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w)); + let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let hidden = |mut i: AppearanceItem| { + match &mut i { + AppearanceItem::Fill(f) => f.visible = false, + AppearanceItem::Stroke(st) => st.visible = false, + } + i + }; + let clear = |mut i: AppearanceItem| { + match &mut i { + AppearanceItem::Fill(f) => f.opacity = 0.0, + AppearanceItem::Stroke(st) => st.opacity = 0.0, + } + i + }; + let mut s = session(); + let g = live(&mut s); + let lower_fill = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), fill(Paint::None)], 1.0); + let hidden_fill = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red()))], 1.0); + let clear_fill = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![clear(fill(red()))], 1.0); + let lower_stroke = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 2.0), stroke(Paint::None, 1.0)], 1.0); + let hidden_stroke = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![hidden(stroke(red(), 2.0))], 1.0); + let thin_stroke = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0); + let clear_stroke = set_live_part(&mut s, g, None, Some((50.0, 50.0)), vec![clear(stroke(red(), 2.0))], 1.0); + let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0); + let parts = [lower_fill, hidden_fill, clear_fill, lower_stroke, hidden_stroke, thin_stroke, clear_stroke, clear_edge]; + assert_eq!(parts.iter().collect::>().len(), parts.len()); + let before = node(&s, g); + s.execute("livePaint.expand", &json!({})).unwrap(); + let n = node(&s, g); + let kept: Vec = n.children().unwrap().iter().map(|c| c.id).collect(); + let has = |id: NodeId| kept.contains(&id); + assert!(has(lower_fill) && has(lower_stroke), "a painted lower row draws: {kept:?}"); + for id in [hidden_fill, clear_fill, hidden_stroke, thin_stroke, clear_stroke, clear_edge] { + assert!(!has(id), "{id:?} draws nothing: {kept:?}"); + } + // The kept parts keep every row. + let rows = |n: &Node, id: NodeId| n.children().unwrap().iter().find(|c| c.id == id).unwrap().appearance.items.clone(); + assert_eq!(rows(&n, lower_fill), rows(&before, lower_fill)); + assert_eq!(rows(&n, lower_stroke), rows(&before, lower_stroke)); +} + +/// Merge carries a face's whole appearance when one of its rows draws, and carries nothing from a +/// face that draws nothing. +#[test] +fn live_paint_merge_carries_the_faces_that_draw() { + use vectorcraft_doc::FillLayer; + use vectorcraft_tools::builder::face_at; + let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p)); + let green = || Paint::solid(Color::rgb(0.0, 1.0, 0.0)); + let mut s = session(); + let g = live(&mut s); + let rows = vec![fill(green()), fill(Paint::None)]; + set_live_part(&mut s, g, Some((25.0, 50.0)), None, rows.clone(), 1.0); + let mut only_hidden = fill(green()); + if let AppearanceItem::Fill(f) = &mut only_hidden { + f.visible = false; + } + set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![only_hidden], 1.0); + let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0); + s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + let n = node(&s, g); + assert_eq!(face_at(&n, Point::new(25.0, 50.0)).unwrap().appearance.items, rows, "the drawn lower fill is carried"); + let b = face_at(&n, Point::new(125.0, 50.0)).unwrap(); + assert_eq!(b.appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), "the face takes its source's fill again"); + assert!(b.appearance.has_drawn_fill()); +} + +/// Merge rebuilds the faces and edges as other paths, so the appearance it carries to them leaves +/// the strokes' pen pressure behind and keeps the rest. +#[test] +fn live_paint_merge_leaves_pen_pressure_behind() { + use vectorcraft_doc::{FillLayer, PressureProfile, StrokeLayer}; + use vectorcraft_tools::builder::{edge_near, face_at}; + let pressed = || { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); + st.pressure = PressureProfile::from_samples([(0.0, 0.2), (0.5, 1.0), (1.0, 0.2)]); + assert!(st.pressure.is_some()); + st + }; + let green = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)))); + let mut s = session(); + let g = live(&mut s); + set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![green.clone(), AppearanceItem::Stroke(pressed())], 1.0); + set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![AppearanceItem::Stroke(pressed())], 1.0); + let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0); + s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + let n = node(&s, g); + let mut plain = pressed(); + plain.pressure = None; + let face = &face_at(&n, Point::new(75.0, 50.0)).unwrap().appearance.items; + assert_eq!(face, &vec![green, AppearanceItem::Stroke(plain.clone())]); + assert_eq!(edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap().appearance.items, vec![AppearanceItem::Stroke(plain)]); +} + +/// The gradients Merge carries to the rebuilt faces and edges keep their fit on the old outline, +/// lower rows included. +#[test] +fn live_paint_merge_keeps_the_fit_of_carried_gradients() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{FillLayer, StrokeLayer}; + use vectorcraft_tools::builder::{edge_near, face_at}; + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + let unplaced = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let gradient = || Paint::Gradient(Box::new(unplaced.clone())); + let mut s = session(); + let g = live(&mut s); + let rows = vec![AppearanceItem::Fill(FillLayer::new(gradient())), AppearanceItem::Fill(FillLayer::new(Paint::None))]; + let face = set_live_part(&mut s, g, Some((25.0, 50.0)), None, rows, 1.0); + let edge = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![AppearanceItem::Stroke(StrokeLayer::new(gradient(), 2.0))], 1.0); + let before = node(&s, g); + let old = |id: NodeId| before.children().unwrap().iter().find(|c| c.id == id).unwrap().clone(); + let (old_face, old_edge) = (old(face), old(edge)); + let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0); + s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + let n = node(&s, g); + let geom = |items: &[AppearanceItem]| match items[0].paint() { + Paint::Gradient(gp) => gp.geom, + p => panic!("a gradient, not {p:?}"), + }; + let face_bounds = old_face.geometric_bounds().unwrap(); + let new_face = face_at(&n, Point::new(25.0, 50.0)).unwrap(); + assert_eq!(geom(&new_face.appearance.items), Some(unplaced.resolve(face_bounds)), "the lower-row fill keeps its fit"); + let AppearanceItem::Stroke(st) = &old_edge.appearance.items[0] else { panic!("a stroke") }; + let edge_box = st.paint_bounds(old_edge.geometric_bounds().unwrap()); + let new_edge = edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap(); + assert_eq!(geom(&new_edge.appearance.items), Some(unplaced.resolve(edge_box)), "the stroke keeps its fit"); +} + +/// Merge carries a face's or edge's visibility, opacity and blending mode to the rebuilt part with +/// its appearance: a half-transparent face drawn by a stroke alone stays half transparent, and a +/// hidden edge stays hidden. +#[test] +fn live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts() { + use vectorcraft_color::BlendMode; + use vectorcraft_doc::StrokeLayer; + use vectorcraft_tools::builder::{edge_near, face_at}; + let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 2.0)); + let mut s = session(); + let g = live(&mut s); + let face = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![stroke()], 0.5); + let edge = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke()], 1.0); + s.edit("t", |d, _| { + d.node_mut(face).unwrap().blend = BlendMode::Multiply; + d.node_mut(edge).unwrap().visible = false; + Ok(()) + }) + .unwrap(); + let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0); + s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + let n = node(&s, g); + let f = face_at(&n, Point::new(25.0, 50.0)).unwrap(); + assert_eq!((f.appearance.items.clone(), f.opacity, f.blend, f.visible), (vec![stroke()], 0.5, BlendMode::Multiply, true)); + let e = edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap(); + assert_eq!((e.appearance.items.clone(), e.visible), (vec![stroke()], false), "the hidden edge stays hidden"); +} + fn live_group_only(s: &mut Session) -> NodeId { let r = s.execute("livePaint.make", &json!({})).unwrap(); NodeId(r["id"].as_u64().unwrap()) @@ -3692,6 +3878,37 @@ fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() { assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_))); } +#[test] +fn live_paint_selection_double_click_selects_the_faces_no_stroked_edge_parts() { + let mut s = session(); + let g = live(&mut s); + // The edge between the middle and right faces draws no stroke. + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100.0, 50.0], "none": true})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("livePaintSelection", v).unwrap(); + let gn = node(&s, g); + let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id; + let before = s.doc().unwrap().doc.clone(); + let double_click = |s: &mut Session, x: f64, m: Mods| { + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + s.pointer(&PointerEvent::new(kind, x, 50.0).with_mods(m), v).unwrap(); + } + let mut ids = s.doc().unwrap().selection.objects.clone(); + ids.sort(); + ids + }; + let sorted = |mut ids: Vec| { + ids.sort(); + ids + }; + assert_eq!(double_click(&mut s, 125.0, Mods::default()), sorted(vec![face(75.0), face(125.0)])); + // The stroked edge at x = 50 parts the left face from the others. + assert_eq!(double_click(&mut s, 25.0, Mods::default()), vec![face(25.0)]); + // Shift-double-click adds the faces reached to the selection. + assert_eq!(double_click(&mut s, 75.0, Mods { shift: true, ..Default::default() }), sorted(vec![face(25.0), face(75.0), face(125.0)])); + assert_eq!(s.doc().unwrap().doc, before, "selecting changes no paint"); +} + #[test] fn live_paint_selection_marquee_selects_what_it_touches() { let mut s = session(); @@ -4229,6 +4446,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..2d024f552b 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -16,7 +16,10 @@ use crate::*; /// Every test here reads or changes the process-wide proof view or colour settings: one at a time. /// Tests that switch the Color Settings away from the defaults live in `tests/color_settings.rs` /// (their own process): every colour shown or rendered goes through them, so no lock could cover -/// all the tests that would see the change. +/// all the tests that would see the change. A unit test that assigns its front document a profile +/// other than the working space changes the active settings the same way until another document +/// comes to the front: it starts with `crate::tests::in_own_process`, which runs it again in a +/// child process. pub(crate) static GLOBAL: Mutex<()> = Mutex::new(()); fn session() -> Session { @@ -184,6 +187,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/dcs.rs b/crates/engine/src/tests/dcs.rs index b780f14c09..43bdf180a7 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -490,6 +490,118 @@ fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() { assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept"); } +/// [`two_plates`] on a 20 × 20 pt page, its composite drawn through the left half of it (same +/// length, so the plates stay where the header says). +fn two_plates_square() -> Vec { + let text = String::from_utf8(two_plates()).unwrap(); + let text = text.replace("0 0 20 10", "0 0 20 20").replace("20 10 scale", "20 20 scale"); + text.replacen("10 10 lineto 0 10 lineto", "10 20 lineto 0 20 lineto", 1).into_bytes() +} + +/// The bounds (width, height) of the clipping path of clip group `g`. +fn clip_size(doc: &Document, g: NodeId) -> (f64, f64) { + let NodeKind::Group { children, clip: true } = &doc.node(g).unwrap().kind else { panic!("a clip group: {:?}", doc.node(g).unwrap().kind) }; + let b = children[0].geometric_bounds().expect("a clipping path"); + (b.width(), b.height()) +} + +#[test] +fn placement_options_clip_a_dcs_image_inside_its_files_clipping_path() { + let dir = temp_dir("placement-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let square = write(&dir, "square.eps", &two_plates_square()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); + let depth = |doc: &Document| std::iter::successors(Some(id), |n| doc.parent_of(*n)).count(); + let mut first = None; + // The square file fills the 20 × 10 pt placement and is cut to it; the file's own clipping + // path, the left half of its page, still clips the image inside that, on every relink. + for _ in 0..2 { + s.execute("links.relink", &json!({ "ids": [id.0], "path": square })).unwrap(); + let doc = &s.doc().unwrap().doc; + let file = doc.parent_of(id).expect("the file's clip group"); + let (w, h) = clip_size(doc, file); + assert!((w - 10.0).abs() < 1e-6 && (h - 20.0).abs() < 1e-6, "the file's clipping path: {w} × {h}"); + let placement = doc.parent_of(file).expect("the placement's clip group"); + let (w, h) = clip_size(doc, placement); + assert!((w - 20.0).abs() < 1e-6 && (h - 10.0).abs() < 1e-6, "the placement's clip: {w} × {h}"); + assert_eq!(*first.get_or_insert(depth(doc)), depth(doc), "no group is added on a later relink"); + } + // An update of the file keeps both. + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(first, Some(depth(doc))); + let file = doc.parent_of(id).unwrap(); + assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path"); + assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip"); + // The file loses its clipping path and gains it again: the same two groups, no more. + let unclipped = String::from_utf8(two_plates_square()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + for _ in 0..2 { + std::fs::write(&square, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(first, Some(depth(doc)), "the file's group stays, without its clip"); + let plain = doc.parent_of(id).unwrap(); + assert!(!matches!(doc.node(plain).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's group is not clipped"); + assert!((clip_size(doc, doc.parent_of(plain).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip stays"); + std::fs::write(&square, two_plates_square()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(first, Some(depth(doc)), "the file's clip group again, at the same depth"); + let file = doc.parent_of(id).unwrap(); + assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path again"); + assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip"); + } +} + +#[test] +fn placement_options_clip_a_dcs_image_in_a_symbol_inside_its_files_clipping_path() { + let dir = temp_dir("placement-clip-symbol"); + let path = write(&dir, "pic.eps", &two_plates()); + let square = write(&dir, "square.eps", &two_plates_square()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image"); + let id = row["id"].as_u64().unwrap(); + s.execute("links.placementOptions", &json!({"ids": [id], "preserve": "fill", "clip": true})).unwrap(); + // The clip groups around the symbol's image, innermost first. + let clips = |s: &Session| { + fn find(n: &Node, id: u64, around: &mut Vec<(f64, f64)>) -> bool { + if n.id.0 == id { + return true; + } + let Some(children) = n.children() else { return false }; + if !children.iter().any(|c| find(c, id, around)) { + return false; + } + if let NodeKind::Group { clip: true, .. } = n.kind + && let Some(b) = children[0].geometric_bounds() + { + around.push((b.width(), b.height())); + } + true + } + let mut around = vec![]; + let doc = &s.doc().unwrap().doc; + assert!(find(&doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art, id, &mut around)); + around + }; + for _ in 0..2 { + s.execute("links.relink", &json!({ "ids": [id], "path": square })).unwrap(); + let around = clips(&s); + assert_eq!(around.len(), 2, "the file's clip group inside the placement's: {around:?}"); + // In the symbol's own units: the file's path as wide as the placement is tall, half as + // wide as it is tall, and the placement twice as wide as it is tall. + let ((fw, fh), (pw, ph)) = (around[0], around[1]); + assert!((fh - 2.0 * fw).abs() < 1e-6 && (fw - ph).abs() < 1e-6, "the file's clipping path: {around:?}"); + assert!((pw - 2.0 * ph).abs() < 1e-6, "the placement's clip: {around:?}"); + } +} + #[test] fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() { let dir = temp_dir("params"); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 8936f8bc6f..9eeb5813bc 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. @@ -5178,6 +5269,47 @@ fn pencil_extend_closes_within_400_pt_at_any_fidelity() { } } +/// `closed`, when given, decides whether a stroke continuing an open path closes it (the tools +/// send it by Close Paths When Ends Are Within): false leaves it open even ending on the other end; +/// true closes one ending 50 pt away with a straight segment back, the stroke's end kept, and one +/// ending 2 pt away on the other end itself. +#[test] +fn pencil_extend_closes_as_closed_says() { + let mut s = session(); + for (end, closed, ends_on_start) in [([0i32, 0], false, false), ([0, 50], true, false), ([0, 2], true, true)] { + let l = line(&mut s, 0.0, 0.0, 200.0, 0.0); + let points = json!([[200, 0], [200, 100], [100, 150], end]); + let p = json!({"points": points, "fidelity": 1.5, "closed": closed, "extend": {"id": l.0}, "keepSelected": false}); + s.execute("path.freehand", &p).unwrap(); + let pd = path(&s, l); + let sp = &pd.subpaths[0]; + assert_eq!(sp.closed, closed, "ending at {end:?}"); + if closed { + let stroke_end = Point::new(f64::from(end[0]), f64::from(end[1])); + let kept = sp.anchors.iter().any(|a| a.p.distance(stroke_end) < 1e-6); + assert_eq!(kept, !ends_on_start, "ending at {end:?}: {:?}", sp.anchors); + let on_start = sp.anchors.iter().filter(|a| a.p.distance(Point::new(0.0, 0.0)) < 1e-6).count(); + assert_eq!((sp.anchors[0].p, on_start), (Point::new(0.0, 0.0), 1), "ending at {end:?}"); + } + } +} + +/// A continued path closed from away from its other end closes with a straight segment, even when +/// that end carries a handle the open path didn't use. +#[test] +fn pencil_extend_closes_far_away_with_a_straight_segment() { + let mut s = session(); + let anchors = json!([{"x": 0, "y": 0, "in": [-40, 30]}, {"x": 200, "y": 0}]); + let l = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap()); + let points = json!([[200, 0], [200, 100], [100, 150], [0, 50]]); + let p = json!({"points": points, "fidelity": 1.5, "closed": true, "extend": {"id": l.0}, "keepSelected": false}); + s.execute("path.freehand", &p).unwrap(); + let sp = path(&s, l).subpaths[0].clone(); + assert!(sp.closed); + let (first, last) = (sp.anchors[0], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((first.h_in, last.h_out), (first.p, last.p), "the closing segment has no handles: {:?}", sp.anchors); +} + /// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never /// crash, and what they draw has finite anchors. #[test] 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/livecorners.rs b/crates/engine/src/tests/livecorners.rs index 190cd0d354..b9e3793913 100644 --- a/crates/engine/src/tests/livecorners.rs +++ b/crates/engine/src/tests/livecorners.rs @@ -126,6 +126,30 @@ fn bad_sides_radii_and_keys_are_errors() { assert_eq!(corners(&s, id).0, [0.0; 4]); } +/// Saved steps play as they did before `sides` and `radius` were checked: a negative radius +/// removes the rounding, a whole side count outside 3 to 1000 is brought into that range, and a +/// fractional or negative side count is ignored. +#[test] +fn saved_steps_with_out_of_range_sides_or_radius_still_play() { + let (mut s, id) = session(); + let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap()); + let replay = |s: &mut Session, p: Value| s.replay("object.setLiveShape", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + run(&mut s, json!({"id": id.0, "radius": 6})); + replay(&mut s, json!({"id": id.0, "radius": -4})); + assert_eq!(corners(&s, id).0, [0.0; 4]); + let sides = |s: &Session| match node_path(s, poly).1 { + Some(LiveShape::Polygon { sides, .. }) => sides, + other => panic!("{other:?}"), + }; + for (n, want) in [(json!(2), 3), (json!(1001), 1000), (json!(2.5), 1000), (json!(-1), 1000), (json!(5.0), 5)] { + replay(&mut s, json!({"ids": [poly.0], "sides": n})); + assert_eq!(sides(&s), want, "{n}"); + } + // A direct call still refuses them. + assert!(s.execute("object.setLiveShape", &json!({"id": id.0, "radius": -4})).is_err()); + assert!(s.execute("object.setLiveShape", &json!({"ids": [poly.0], "sides": 2})).is_err()); +} + #[test] fn direct_selected_corners_round_alone_and_stay_selected() { let (mut s, id) = session(); diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 209d22a42a..094f634799 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -683,6 +683,9 @@ fn the_new_document_links_from_the_profile_file() { /// is a warning. #[test] fn a_profile_brings_the_color_profile_its_file_carries() { + if !super::in_own_process("tests::newdoc::a_profile_brings_the_color_profile_its_file_carries") { + return; + } use vectorcraft_color::cms; let (mut s, dir) = with_profiles("icc"); let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); 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/rasterfilters.rs b/crates/engine/src/tests/rasterfilters.rs index 236580d5b7..534f79b1bc 100644 --- a/crates/engine/src/tests/rasterfilters.rs +++ b/crates/engine/src/tests/rasterfilters.rs @@ -5,6 +5,7 @@ //! as images of the effected object. use serde_json::{Value, json}; +use vectorcraft_color::cms; use vectorcraft_doc::NodeKind; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; @@ -173,16 +174,22 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() { EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO) { let (mut s, g) = striped(); + // Every color drawn goes through the process-wide color settings. A test that changes them + // runs in a process of its own (`crate::tests::in_own_process`). + let colors = cms::active(); + assert_eq!(cms::active_settings(), cms::ColorSettings::default(), "{id}: another test left the color settings changed"); let plain = render(&s); let params: Value = serde_json::from_str(params).unwrap(); s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap(); let a = render(&s); - assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); - assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic"); + let again = render(&s); // The multithreaded pipeline draws the same. let mut mt = Renderer::new(); mt.threads = 4; let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + assert!(std::sync::Arc::ptr_eq(&colors, &cms::active()), "{id}: another test changed the color settings while this one drew"); + assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); + assert_eq!(a.pixels, again.pixels, "{id} is deterministic"); assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b)); } } diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index a5b19563a9..0f7205eaae 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -112,6 +112,9 @@ fn a_linked_pngs_bytes_are_embedded() { #[test] fn an_embedded_profile_is_restored() { + if !super::in_own_process("tests::saveoptions::an_embedded_profile_is_restored") { + return; + } let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); let icc = cms::icc_bytes(&builtin.name).unwrap(); let name = format!("Studio RGB {}", std::process::id()); 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/src/tests/symbollib.rs b/crates/engine/src/tests/symbollib.rs index 54591c4658..c5d32e822a 100644 --- a/crates/engine/src/tests/symbollib.rs +++ b/crates/engine/src/tests/symbollib.rs @@ -84,7 +84,7 @@ fn the_builtin_library_is_original_art_generated_in_code() { let mut s = Session::new(); let list = run(&mut s, "symbol.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); - assert_eq!(libs.len(), 5, "{libs:?}"); + assert_eq!(libs.len(), 6, "{libs:?}"); assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn"))); assert_eq!( (libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()), @@ -102,6 +102,10 @@ fn the_builtin_library_is_original_art_generated_in_code() { (libs[4]["id"].as_str(), libs[4]["name"].as_str(), libs[4]["category"].as_str()), (Some("skill-icons"), Some("Skill Icons"), Some("builtIn")) ); + assert_eq!( + (libs[5]["id"].as_str(), libs[5]["name"].as_str(), libs[5]["category"].as_str()), + (Some("weather-icons"), Some("Weather Icons"), Some("builtIn")) + ); let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"})); let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]); @@ -215,6 +219,32 @@ fn map_markers_symbols_add_without_nine_slice() { )); } +#[test] +fn weather_icons_library_lists_and_places() { + let mut s = session(); + let lib = run(&mut s, "symbol.library", json!({"library": "weather-icons"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Sun", "Moon", "Cloud", "Rain", "Thunder", "Snow"]); + let mut placed = Vec::new(); + for (i, name) in names.iter().enumerate() { + let r = run( + &mut s, + "symbol.addFromLibrary", + json!({"library": "weather-icons", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}), + ); + assert_eq!(r["added"], json!([name])); + placed.push(r["placed"].as_u64().unwrap()); + } + let d = doc(&s); + for (i, name) in names.iter().enumerate() { + assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides"); + assert!(matches!( + d.node(NodeId(placed[i])).map(|n| &n.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == *name + )); + } +} + #[test] fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/engine/src/tests/textimport.rs b/crates/engine/src/tests/textimport.rs index 0749964ee6..b369346096 100644 --- a/crates/engine/src/tests/textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -68,6 +68,49 @@ fn type_placed_on_an_artboard_edge_takes_that_artboards_frame() { } } +/// `text` as UTF-16 without a byte-order mark, little-endian when `le` is set. +fn utf16(text: &str, le: bool) -> Vec { + text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect() +} + +/// Unicode text in UTF-16 without a byte-order mark is read as UTF-16 when at least half of its +/// two-byte units hold a NUL byte, in the byte order that reads a line break, or for one line, the +/// order its NUL bytes give. Other bytes keep today's reading. +#[test] +fn utf16_without_a_byte_order_mark_places_by_its_nul_bytes_and_line_breaks() { + let mut s = session(); + for le in [true, false] { + let r = place_text(&mut s, &utf16("Hello, 世界 café!\r\nSecond line", le), json!({})); + assert_eq!(placed(&s, &r).0, "Hello, 世界 café!\nSecond line", "little-endian: {le}"); + let r = place_text(&mut s, &utf16("One line, 世界", le), json!({})); + assert_eq!(placed(&s, &r).0, "One line, 世界", "little-endian: {le}"); + } + // One line with NUL bytes in both places (U+4E00 ends in a zero byte) takes the order more of + // its units give, and a tie is little-endian. + let r = place_text(&mut s, &utf16("一AB", false), json!({})); + assert_eq!(placed(&s, &r).0, "一AB"); + let r = place_text(&mut s, &utf16("一A", true), json!({})); + assert_eq!(placed(&s, &r).0, "一A"); + // Not read as UTF-16, so read as UTF-8 or else Windows-1252 with NUL bytes dropped: an odd + // length, an unpaired surrogate, a NUL character, and line breaks in both byte orders (U+0A00). + let r = place_text(&mut s, b"A\0B", json!({})); + assert_eq!(placed(&s, &r).0, "AB"); + let r = place_text(&mut s, b"\0a\0b\xd8\0", json!({})); + assert_eq!(placed(&s, &r).0, "abØ"); + let r = place_text(&mut s, b"\0\0\xc3\xa9", json!({})); + assert_eq!(placed(&s, &r).0, "é"); + let r = place_text(&mut s, &utf16("A\u{0A00}\nB", true), json!({})); + assert_eq!(placed(&s, &r).0, "A\n\nB"); + // Fewer than half of the two-byte units hold a NUL byte: UTF-8, and Windows-1252. + let r = place_text(&mut s, "Fünf\0 Zeilen!".as_bytes(), json!({})); + assert_eq!(placed(&s, &r).0, "Fünf Zeilen!"); + let r = place_text(&mut s, b"caf\xe9\0x", json!({})); + assert_eq!(placed(&s, &r).0, "caféx"); + // ANSI reads each byte as a character of the 8-bit set; NUL and other control bytes are dropped. + let r = place_text(&mut s, &utf16("Hi, 世界", true), json!({"text": {"characterSet": "ansi"}})); + assert_eq!(placed(&s, &r).0, "Hi, NLu"); +} + #[test] fn text_files_report_and_queue_with_their_options() { let mut s = session(); diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index e896a0ab32..2ec7667bde 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -241,6 +241,69 @@ fn options_are_saved_with_the_preferences() { assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9)); } +#[test] +fn spiral_decay_is_normalized_for_active_and_inactive_tools_and_preferences() { + let mut s = session(); + for active in [false, true] { + select(&mut s, if active { "spiral" } else { "selection" }); + let options = s.set_tool_option_cmd(&json!({"tool": "spiral", "values": {"decay": 150, "segments": 4, "clockwise": false}})).unwrap(); + assert_eq!(options["decay"], json!(99.99)); + assert_eq!(s.tool_options_of("spiral")["decay"], json!(99.99)); + assert_eq!(s.prefs.tool_settings["spiral"]["decay"], json!(99.99)); + assert_eq!(s.tool_id(), if active { "spiral" } else { "selection" }); + select(&mut s, "selection"); + select(&mut s, "spiral"); + assert_eq!(s.tool_options()["decay"], json!(99.99)); + } + let saved = serde_json::to_string(&s.prefs).unwrap(); + let mut restored = session(); + restored.apply_prefs(serde_json::from_str(&saved).unwrap()); + select(&mut restored, "spiral"); + assert_eq!(restored.tool_options(), json!({"decay": 99.99, "segments": 4, "clockwise": false})); +} + +#[test] +fn legacy_spiral_decay_preferences_report_the_ratio_drawn_after_reopening() { + use vectorcraft_geom::Point; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + // Older versions saved 100–150, although their geometry already capped the ratio at 0.9999. + for clockwise in [false, true] { + let mut s = session(); + let mut prefs = Prefs::default(); + prefs.tool_settings.insert("spiral".into(), json!({"decay": 150, "segments": 4, "clockwise": clockwise}).as_object().unwrap().clone()); + s.apply_prefs(serde_json::from_str(&serde_json::to_string(&prefs).unwrap()).unwrap()); + let view = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("spiral", view).unwrap(); + let decay = s.tool_options()["decay"].as_f64().unwrap(); + assert_eq!(decay, 99.99); + let before = s.doc().unwrap().history.undo.len(); + for (kind, x) in [(PointerKind::Down, 200.0), (PointerKind::Drag, 300.0), (PointerKind::Up, 300.0)] { + s.pointer(&PointerEvent::new(kind, x, 200.0), view).unwrap(); + } + let d = s.doc().unwrap(); + assert_eq!(d.history.undo.len(), before + 1); + assert_eq!(d.selection.objects.len(), 1); + let id = d.selection.objects[0]; + let path = d.doc.node(id).unwrap().path_data().unwrap().clone(); + let anchors = &path.subpaths[0].anchors; + assert_eq!(anchors.len(), 5); + assert!(!path.is_closed()); + let center = Point::new(200.0, 200.0); + // The path runs from the centre end outwards, and decay is per full wind: four quarter-turn + // segments give the decay. + for pair in anchors.windows(2) { + let ratio = pair[0].p.distance(center) / pair[1].p.distance(center); + assert!((100.0 * ratio.powi(4) - decay).abs() < 1e-9); + } + assert_eq!(s.prefs.tool_settings["spiral"]["decay"], json!(99.99)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap(), &path); + } +} + /// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its /// arrows changed, in the saved options and the Corner Radius preference too. #[test] diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index eea8ae4986..6256088842 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2201,6 +2201,16 @@ fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() { let e = import(&mut s, &bytes).unwrap_err().to_string(); assert!(e.contains("CSV has a header but no data rows"), "{e}"); } + // With no line break, the side of each unit that holds the NUL byte gives the byte order, and a + // tie is little-endian. + let order = crate::cmd::utf16_order_without_mark; + assert_eq!(order(&utf16("City", false)), Some(false)); + assert_eq!(order(&utf16("City", true)), Some(true)); + assert_eq!(order(b"\0aa\0"), Some(true)); + assert_eq!(order(&[utf16("City\n", false), vec![0x0A]].concat()), Some(false), "an odd last byte is left out"); + // Read big-endian, this one-line file ends in an unpaired surrogate. + let e = import(&mut s, b"\0a\0b\xd8\0").unwrap_err().to_string(); + assert!(e.contains("CSV holds NUL bytes"), "{e}"); import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap(); assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目")); // Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index f507b765c6..1a41488285 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -1115,6 +1115,92 @@ fn lasso_picks_gradient_mesh_points() { assert_eq!(red, top, "only the points inside the loop are recoloured"); } +/// With gradient mesh points picked, the Fill proxy (and the Color panel that edits it) shows +/// the color a solid fill gives them, not the mesh object's own fill: "?" when the picked +/// points' colors differ, the object's fill again once no point is picked. +#[test] +fn the_fill_proxy_shows_the_picked_mesh_points_color() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let object_fill = s.proxy_paints().0; + // Round the top row of points. + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let shown = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()), "the picked points' color: {shown}"); + assert_eq!(shown["fillMixed"], json!(false)); + assert_eq!(shown["fill"], json!(s.proxy_paints().0)); + // One picked point takes another color: the points differ. + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + let first = m.idx(0, 0); + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": first, "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points' colors differ"); + // Picking none (the Selection tool) shows the object's fill again. + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.proxy_paints().0, object_fill); + assert!(!s.proxy_mixed().0); +} + +/// With the Appearance panel's stroke row active, a solid fill still recolors the picked mesh +/// points, so the Fill proxy shows them; with its fill row active, the fill goes to that row, and +/// the proxy shows the row. +#[test] +fn the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + let items = &s.doc().unwrap().doc.node(a).unwrap().appearance.items; + let fill_row = items.iter().position(|it| it.is_fill()).expect("a fill row"); + let stroke_row = items.iter().position(|it| !it.is_fill()).expect("a stroke row"); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + assert_eq!(s.fill_mesh_points().first().map(|(_, v)| v.len()), Some(3), "the top row is picked"); + let point_color = |s: &Session| { + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + m.points[m.idx(0, 0)].color.to_hex() + }; + + s.execute("appearance.setActiveItem", &json!({"index": stroke_row})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the stroke row leaves the fill on the picked points"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0); + + s.execute("appearance.setActiveItem", &json!({"index": fill_row})).unwrap(); + assert!(s.fill_mesh_points().is_empty()); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the fill row takes the fill"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#00ff00".into())); +} + +/// A gradient mesh with points picked selected with another object: a solid fill recolors the +/// points and fills the other object, and the Fill proxy and its "?" count the points' colors. +#[test] +fn the_fill_proxy_counts_picked_mesh_points_beside_other_objects() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + assert_eq!(s.fill_mesh_points().first().map(|(id, v)| (*id, v.len())), Some((a, 3)), "the top row is still picked"); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#3366ff"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0, "the points and the other object are the same color"); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points and the other object's fill differ"); +} + /// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are /// deselected and the others selected, the rest of the selection staying as it was. #[test] 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..c2d47e9d5e 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] @@ -1721,8 +1771,9 @@ fn both_points_snap_onto_anchors_with_a_visible_indicator() { } /// Smart Guides for the Linear picks: hovering away from any line, the first point slides into -/// line with another object's corner and its guides show before the click; the second slides onto -/// the construction guide through the first. The dimension measures between the snapped points. +/// line with another object's corner and its guides show before the click; the second slides into +/// line with the other corner, with no construction guide through the first point pulling it +/// sideways. The dimension measures between the snapped points. #[test] fn linear_picks_show_and_snap_to_smart_guides() { use vectorcraft_engine::tools::Overlay; @@ -1738,18 +1789,20 @@ fn linear_picks_show_and_snap_to_smart_guides() { assert!(o.iter().any(|o| matches!(o, Overlay::Line { .. } | Overlay::Label { .. })), "its guides show before the click: {o:?}"); ev(&mut s, PointerKind::Down, 250.0, 202.5); ev(&mut s, PointerKind::Up, 250.0, 202.5); - // Straight below the first point, 1.5 pt off: the rubber band ends on the vertical through it. - ev(&mut s, PointerKind::Move, 251.5, 260.0); + // Below the first point, 1.5 pt to its right and 2.5 pt off the bottom edge's level: the + // rubber band ends on that level, where the pointer is across it. + ev(&mut s, PointerKind::Move, 251.5, 262.5); let end = overlays(&mut s).into_iter().find_map(|o| if let Overlay::Line { b, dashed: true, .. } = o { Some(b) } else { None }).unwrap(); - assert_eq!((end.x, end.y), (250.0, 260.0), "on the construction guide"); - ev(&mut s, PointerKind::Down, 251.5, 260.0); - ev(&mut s, PointerKind::Up, 251.5, 260.0); + assert_eq!((end.x, end.y), (251.5, 260.0), "in line with the bottom edge, not on a vertical through the first point"); + ev(&mut s, PointerKind::Down, 251.5, 262.5); + ev(&mut s, PointerKind::Up, 251.5, 262.5); ev(&mut s, PointerKind::Move, 220.0, 230.0); assert!(!overlays(&mut s).iter().any(|o| matches!(o, Overlay::Label { .. })), "placing: the guides are gone"); ev(&mut s, PointerKind::Down, 220.0, 230.0); ev(&mut s, PointerKind::Up, 220.0, 230.0); let id = s.doc().unwrap().selection.objects[0].0; - assert_eq!(text(&s, id), "60.00 pt"); + // Aligned from (250, 200) to (251.5, 260): √(1.5² + 60²). + assert_eq!(text(&s, id), "60.02 pt"); } /// The page content streams of exported PDF `v`, inflated. diff --git a/crates/engine/tests/it/three_d_light_color.rs b/crates/engine/tests/it/three_d_light_color.rs index 38013a372b..381bc92f15 100644 --- a/crates/engine/tests/it/three_d_light_color.rs +++ b/crates/engine/tests/it/three_d_light_color.rs @@ -481,6 +481,151 @@ fn solid_gradient_opacity_is_applied_once_even_without_shading() { } } +/// How `image`, `d` drawn at 1:1 on 400 × 400 pixels, differs from `d` drawn at 3x and averaged +/// down, on the pixels inside the art: those whose 3x average has some alpha, the same as every +/// pixel within 3 of them. Left out too are pixels where the 3x average bends sharply (a channel's +/// second difference across the pixel, along x or y, over 4 levels), such as the rim where a cap +/// meets a body: a gradient sampled at each pixel's center matches an area average only where the +/// light changes close to linearly across the pixel. Returns (pixels measured, premultiplied RGB +/// channels more than 3 levels off, the largest RGB difference, alphas off: below 255 where the 3x +/// average is opaque, otherwise more than 3 levels off, the first few pixels off). +fn seams_against_supersampled(d: &Document, image: &vectorcraft_render::Rendered) -> (usize, usize, u8, usize, Vec) { + let fine = vectorcraft_render::Renderer::new().render( + d, + 1200, + 1200, + kurbo::Affine::scale(3.0), + &vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() }, + ); + let average = fine.area_resized(400, 400).unwrap(); + let (mut measured, mut color, mut max, mut holes) = (0, 0, 0, 0); + let mut worst = vec![]; + for y in 3..397 { + for x in 3..397 { + let q = average.pixel(x, y); + if q[3] == 0 || !(y - 3..=y + 3).all(|y| (x - 3..=x + 3).all(|x| average.pixel(x, y)[3] == q[3])) { + continue; + } + let bend = |a: [u8; 4], b: [u8; 4]| (0..3).any(|c| (i32::from(a[c]) + i32::from(b[c]) - 2 * i32::from(q[c])).abs() > 4); + if bend(average.pixel(x - 1, y), average.pixel(x + 1, y)) || bend(average.pixel(x, y - 1), average.pixel(x, y + 1)) { + continue; + } + measured += 1; + let p = image.pixel(x, y); + for c in 0..3 { + let diff = p[c].abs_diff(q[c]); + max = max.max(diff); + color += usize::from(diff > 3); + } + let hole = if q[3] == 255 { p[3] < 255 } else { p[3].abs_diff(q[3]) > 3 }; + holes += usize::from(hole); + if (p.into_iter().zip(q).any(|(a, b)| a.abs_diff(b) > 3) || hole) && worst.len() < 4 { + worst.push(format!("({x},{y}) {p:?} vs {q:?}")); + } + } + } + (measured, color, max, holes, worst) +} + +/// Colored lights on smooth 3D surfaces, compared at 1:1 with a 3x render averaged down, on every +/// pixel inside the art ([`seams_against_supersampled`]). A solid fill draws as one batched surface; +/// a source gradient composes each facet on its own, and one with transparent stops applies their +/// opacity once over the surface. Inflate seen straight on, one layer of facets, has no alpha off, and +/// in opaque fills no RGB channel more than 3 levels off. Inflate turned so facets narrow to slivers +/// at the edges, and Revolve with a cap over its body, draw facets over other facets, and have no +/// alpha off. +#[test] +fn colored_light_surfaces_match_supersampling_at_internal_facet_edges() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + let fills = |start: [u32; 2], end: [u32; 2]| { + [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6} + ]}}), + ] + }; + let mut cases = vec![]; + for rotation in [(0, 0), (50, 35)] { + let (mut s, _) = setup("threeD.inflate"); + let change = json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights,"rotationX":rotation.0,"rotationY":rotation.1}}); + s.execute("effect.setParams", &change).unwrap(); + cases.push((format!("inflate {rotation:?}"), s, fills([100, 150], [200, 150]), rotation == (0, 0))); + } + let (mut s, _) = capped_profile(); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + cases.push(("revolve".into(), s, fills([80, 200], [240, 200]), false)); + let mut failures = vec![]; + for (name, mut s, fills, one_layer) in cases { + for fill in fills { + s.execute("paint.setFill", &fill).unwrap(); + let image = render(doc(&s)); + let (measured, color, max, holes, worst) = seams_against_supersampled(doc(&s), &image); + assert!(measured > 2_000, "{name} {fill}: {measured} pixels inside"); + let opaque = !fill.to_string().contains("opacity"); + if holes > 0 || (one_layer && opaque && color > 0) { + failures.push(format!( + "{name} {fill}: of {measured} pixels, {color} channels off by more than 3 (at most {max}), {holes} alphas off: {worst:?}" + )); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// The closed facet edges of colored-light surfaces survive SVG and PDF, read back and drawn at +/// 1:1, and Expand Appearance: inside the art, every pixel stays opaque and within 6 levels of the +/// live art. +#[test] +fn colored_light_seams_stay_closed_in_svg_pdf_and_expansion() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + for fill in [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":[100,150],"end":[200,150],"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + ] { + let (mut s, _) = setup("threeD.inflate"); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + let live = render(doc(&s)); + let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned(); + assert!(svg.contains(">(); - 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.rs b/crates/eps/src/ai_editing.rs index 0048cb945f..3df902b97f 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -228,8 +228,9 @@ const GRID_GRAY: f64 = 200.0 / 255.0; /// `%%Creator`: the creator line VectorCraft writes in the header of the editing data. It names /// VectorCraft and the format's app, and holds no version number; the version is written on its -/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). -const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes +/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). The reader reads the symbols of data with +/// this creator at their own size, as [`export_ai_editing`] writes them on a large canvas. +pub(crate) const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes /// The first line of a PostScript file (the DSC version comment). const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a3b2800fd3..59b6b04e4e 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() { @@ -4473,8 +4495,8 @@ fn type_the_data_cant_hold_is_written_as_its_outlines() { rings.subpaths.extend(shapes::rectangle(Rect::new(120.0, 30.0, 170.0, 70.0)).subpaths); holed.kind = vectorcraft_doc::TextKind::Area { frame: rings }; holed.xf = Affine::IDENTITY; - // A carriage return in the characters: a story ends its paragraphs with it. Area type that - // flows around a wrap object: the wrap object's settings aren't written. + // A carriage return in the characters: a story ends its paragraphs with it. Area type with + // wrap shapes and no wrap object: the text document doesn't hold the shapes. let returned = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "A\rB", style(10.0, Color::BLACK)); let mut wrapped = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped round", style(10.0, Color::BLACK)); wrapped.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)) }; @@ -7358,6 +7380,367 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() { assert_eq!(art[0].name.as_deref(), Some("Photo")); } +/// A 64 × 64 RGB image, every pixel of it different, 128 pt square at (20, 10) in `doc`. +fn speckled_photo(doc: &mut Document) -> Node { + let px = image::RgbImage::from_fn(64, 64, |x, y| image::Rgb([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8])); + image_node(doc, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None) +} + +/// The sizes `%%BeginData:` gives the images' samples in the program written from `doc`. +fn sample_sizes(doc: &Document) -> Vec { + let program = export_ai_editing(doc, &opts()).unwrap().program; + String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%%BeginData: ")?.trim().parse().ok()).collect() +} + +/// The samples of a 64 × 64 RGB image, as `%%BeginData:` counts them (`XI` and CR LF too). +const SPECKLED_WHOLE: usize = 64 * 64 * 3 + 4; + +/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp or an +/// envelope on an image) are written as the image whole once, then each as the part of it that its +/// triangle shows: the file holds a little more than the image's pixels rather than a copy for every +/// piece, and it reads back drawing as it did. +#[test] +fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each of a warped image's 200 pieces was written with all of the image's samples. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 10, false, false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 200, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image"); + // Each piece holds its cell (two triangles share one) and 2 pixels more around it. + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// The pieces of a warped image that Preserve Shape Using Transparency cuts with opacity masks that +/// clip are written as the parts their masks show, in a group without knockout, and the file reads +/// back drawing as it did. +#[test] +fn masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each masked piece of a warped image was written with all of the image's samples. + // GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true) + }); + assert_eq!(doc.layers[0].children().unwrap()[0].knockout, vectorcraft_doc::Knockout::On); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 32, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image"); + let total: usize = sizes.iter().sum(); + assert!(total < 6 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// The masked pieces of a warped image whose pixels are half transparent read back drawing as they +/// did, except where neighboring pieces overlap: there the image's alpha draws twice. +#[test] +fn masked_pieces_of_a_translucent_warped_image_read_back_but_for_their_overlaps() { + // GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it + // (8,942 of 20,000 pixels differed). + let doc = photo_doc(|d| { + let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 128])); + let photo = image_node(d, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true) + }); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.05 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// An image with a live Warp keeps its pixels whole once, and its drawn look's pieces are each the +/// part of them they show: it reads back as the image with its Warp live. +#[test] +fn an_image_with_a_live_warp_writes_its_pixels_whole_once() { + // GUARDS: a live Warp's drawn look wrote all of the image's samples for each of its pieces. + let doc = photo_doc(|d| { + let mut photo = speckled_photo(d); + photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into(); + photo + }); + let sizes = sample_sizes(&doc); + assert!(sizes.len() > 100, "{sizes:?}"); + assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "{sizes:?}"); + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{:#?}", lines(&back)); + assert!(matches!(art[0].kind, NodeKind::Image(_)), "{:?}", art[0].kind); + assert_eq!(art[0].appearance.effects.len(), 1, "{:?}", art[0].appearance); +} + +/// An image cut to a clipping path is written with all of its pixels when it is the first of them +/// written, so releasing the clipping path shows the whole image; a second copy cut to a small part +/// is written as that part, 2 pixels more on each side. +#[test] +fn a_clipped_image_written_first_keeps_all_of_its_pixels() { + let window = |d: &mut Document| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + group(d, vec![corner, photo], true) + }; + let doc = photo_doc(|d| { + let (a, b) = (window(d), window(d)); + group(d, vec![a, b], false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 2, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE); + // The path shows 10 × 10 pixels. + assert_eq!(sizes[1], 14 * 14 * 3 + 4, "{sizes:?}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.002 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// The images in `n` and its members, each as its count of effects, its width and its height. +fn images_in(n: &Node, out: &mut Vec<(usize, u32, u32)>) { + if let NodeKind::Image(im) = &n.kind { + out.push((n.appearance.effects.len(), im.width, im.height)); + } + for c in n.children().into_iter().flatten() { + images_in(c, out); + } +} + +/// A copy of an image written whole already that its clipping path shows none of is written as one +/// pixel of it. +#[test] +fn a_copy_of_an_image_that_does_not_show_is_one_pixel() { + // GUARDS: a copy whose clipping path missed the image was written with all of its pixels. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + let first = group(d, vec![corner, photo], true); + let photo = speckled_photo(d); + let away = clipping(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 5.0))); + let second = group(d, vec![away, photo], true); + group(d, vec![first, second], false) + }); + assert_eq!(sample_sizes(&doc), [SPECKLED_WHOLE, 3 + 4]); +} + +/// A 200 × 100 document of: the speckled image when `first`; a square whose opacity mask's art is +/// the image then six squares; the image in a small clipping group. +fn mask_art_then_clipped_photo(first: bool) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + if first { + let photo = speckled_photo(&mut doc); + doc.insert(Some(layer), usize::MAX, photo).unwrap(); + } + let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5)); + // The image, then more objects than the clipping group after the mask holds. + let mut members = vec![speckled_photo(&mut doc)]; + for i in 0..6 { + let x = f64::from(i) * 10.0; + members.push(square(&mut doc, Rect::new(x, 0.0, x + 5.0, 5.0), Color::rgb(1.0, 1.0, 1.0))); + } + let art = group(&mut doc, members, false); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + doc.insert(Some(layer), usize::MAX, masked).unwrap(); + let photo = speckled_photo(&mut doc); + let corner = clipping(&mut doc, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + let clipped = group(&mut doc, vec![corner, photo], true); + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + doc +} + +/// For each object limit from none to all of `doc`'s objects: whether the mask is written, and the +/// sizes `%%BeginData:` gives the images' samples on `%_` lines (the mask's art) and on the others. +fn samples_within_each_limit(doc: &Document) -> Vec<(usize, bool, Vec, Vec)> { + let read = crate::import::objects_read(&export_ai_editing(doc, &opts()).unwrap().program).unwrap(); + (0..=read) + .map(|max| { + let text = String::from_utf8_lossy(&export_within(doc, &opts(), max).unwrap().program).replace("\r\n", "\n"); + let size = |l: &str| l.strip_prefix("%%BeginData: ")?.trim().parse::().ok(); + let hidden = text.lines().filter_map(|l| size(l.strip_prefix("%_")?)).collect(); + let shown = text.lines().filter_map(size).collect(); + (max, text.contains("(AI9_Mask)"), hidden, shown) + }) + .collect() +} + +/// An image whose whole copy was in art left out for the object limit (here an opacity mask's art) +/// is written whole again where it is next written, even cut to a small clipping path: the file +/// always holds all of its pixels once. +#[test] +fn an_image_whose_whole_copy_is_left_out_is_written_whole_again() { + // GUARDS: once the mask's art was left out, the clipped copy was written as the part it shows. + let mut left_out_with_copy = 0; + for (max, mask, hidden, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(false)) { + if hidden.is_empty() && shown.is_empty() { + continue; + } + assert!(hidden.contains(&SPECKLED_WHOLE) || shown.contains(&SPECKLED_WHOLE), "room for {max}: {hidden:?} {shown:?}"); + left_out_with_copy += usize::from(!mask); + } + assert!(left_out_with_copy > 0, "no room left the mask out and kept the clipped copy"); +} + +/// An image written whole, then whole again in an opacity mask's art left out for the object limit, +/// is not written whole a third time: the first copy is kept, and the clipped copy after the mask is +/// the part it shows. +#[test] +fn an_image_written_whole_before_art_left_out_stays_written_whole() { + // GUARDS: the mask's copy took the first copy's place, so giving it back wrote the clipped copy whole. + let mut left_out = 0; + for (max, mask, _, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(true)) { + assert!(shown.iter().filter(|s| **s == SPECKLED_WHOLE).count() <= 1, "room for {max}: {shown:?}"); + left_out += usize::from(!mask && shown.len() == 2); + } + assert!(left_out > 0, "no room left the mask out and kept both copies"); +} + +/// An image with a live Warp in a small clipping group keeps all of its pixels: its drawn look is +/// written first, and neither the look nor the image itself is cut to the clipping path, so it +/// reads back with its Warp live and every pixel it had. +#[test] +fn a_clipped_image_with_a_live_warp_keeps_all_of_its_pixels() { + // GUARDS: the image under a live Warp was cut to the clipping path around it. + let doc = photo_doc(|d| { + let mut photo = speckled_photo(d); + photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into(); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 60.0, 50.0))); + group(d, vec![corner, photo], true) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "the look's first piece: {sizes:?}"); + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + // The image itself, on `%_` lines. + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let hidden: Vec = + String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%_%%BeginData: ")?.trim().parse().ok()).collect(); + assert_eq!(hidden, [SPECKLED_WHOLE]); + let back = read_back(&doc); + let mut images = vec![]; + for n in back.layers[0].children().unwrap() { + images_in(n, &mut images); + } + assert_eq!(images, [(1, 64, 64)], "{:#?}", lines(&back)); +} + +/// An image cut by an opacity mask whose art has an effect that paints past its path keeps the +/// pixels the effect shows: a copy of an image written whole already is cut to all that its mask +/// paints. +#[test] +fn a_masked_image_keeps_the_pixels_its_mask_effects_show() { + // GUARDS: the copy was cut to the mask art's path, short of what its Offset Path paints. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(20.0, 10.0, 24.0, 14.0))); + let first = group(d, vec![corner, photo], true); + let mut masked = speckled_photo(d); + let mut ap = Appearance::basic(solid(Color::WHITE), Paint::None, 0.0); + ap.effects = vec![fx("path.offsetPath", serde_json::json!({"offset": 30.0}))]; + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 40.0, 80.0, 50.0)), ap); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true })); + group(d, vec![first, masked], false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 2, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE); + // The offset art covers (40, 10) to (110, 80): 35 × 35 pixels of the image. + assert!(sizes[1] >= 35 * 35 * 3, "{sizes:?}"); +} + +/// The speckled image with an opacity mask that clips it to six small white squares in its corner. +fn photo_masked_to_its_corner(doc: &mut Document) -> Node { + let mut masked = speckled_photo(doc); + let squares = (0..6).map(|i| square(doc, Rect::new(20.0 + f64::from(i) * 5.0, 10.0, 23.0 + f64::from(i) * 5.0, 20.0), Color::WHITE)).collect(); + let art = group(doc, squares, false); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true })); + masked +} + +/// A copy of an image written whole already whose opacity mask is left out for the object limit is +/// written whole: without the mask, it shows all of its pixels. +#[test] +fn a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels() { + // GUARDS: the copy was cut to its mask, and the mask then left out, so it read back cropped. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let masked = photo_masked_to_its_corner(d); + group(d, vec![photo, masked], false) + }); + let (mut left_out, mut kept) = (0, 0); + for (max, mask, _, shown) in samples_within_each_limit(&doc) { + if shown.len() < 2 { + continue; + } + if mask { + assert!(shown[1] < SPECKLED_WHOLE, "room for {max}: {shown:?}"); + kept += 1; + } else { + assert_eq!(shown, [SPECKLED_WHOLE, SPECKLED_WHOLE], "room for {max}"); + left_out += 1; + } + } + assert!(left_out > 0 && kept > 0, "{left_out} limits left the mask out, {kept} kept it"); +} + +/// A copy of an image written whole already whose opacity mask is nested too deep to be written is +/// written whole. +#[test] +fn a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels() { + // GUARDS: the copy was cut to its mask, and the mask then left out for its depth. + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let photo = speckled_photo(&mut doc); + doc.insert(Some(layer), usize::MAX, photo).unwrap(); + // Isolated instances are written as their art, each a level deeper: the masked copy is at the limit. + let art = photo_masked_to_its_corner(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(art) }); + for i in 1..writer::MAX_NEST { + let mut inner = instance(&mut doc, &format!("S{}", i - 1), Affine::IDENTITY); + inner.isolate = true; + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(inner) }); + } + let mut outer = instance(&mut doc, &format!("S{}", writer::MAX_NEST - 1), Affine::IDENTITY); + outer.isolate = true; + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::DEEP_MASKS), "{:?}", out.warnings); + // The symbols' definitions hold copies too: each copy written with its mask is the part the + // mask shows, and each copy whose mask is left out is whole. + let sizes = sample_sizes(&doc); + let whole = sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(); + let masks = String::from_utf8_lossy(&out.program).matches("(AI9_Mask)").count(); + assert_eq!(sizes.len() - whole, masks, "{sizes:?}"); + assert!(whole >= 2, "the first copy and a copy whose mask is left out: {sizes:?}"); +} + /// [`warped_photo`] linked to `/Users/someone/Job/art/photo.png`, in folder `art` next to the /// document's. fn linked_warped_photo(doc: &mut Document, effects: Vec) -> Node { @@ -10202,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 765856753c..6361efe5ff 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -281,6 +281,17 @@ pub(super) struct Writer<'a> { /// How many more envelopes may be written live: as many as the reader checks in one file /// ([`crate::import::ENVELOPE_CHECKS`]). envelopes_left: usize, + /// The image read last and its pixels ([`images::Samples::of`], `None` when they can't be + /// read): the pieces of a warped image, which follow one another, read them once. + last_samples: Option<(vectorcraft_doc::ImageBlob, Option)>, + /// The images written whole so far, by the address of their data (which each entry keeps), each + /// with the index of its samples in [`Self::payloads`]. + whole_images: HashMap>, usize)>, + /// The outlines of the clipping groups the art being written is in, in the document (empty in + /// a definition, an opacity mask's art and a brush's art, which are written apart from them). + shown: Vec, + /// The copy of an image that the last [`Self::image`] wrote as a window, when it did. + windowed: Option, } impl<'a> Writer<'a> { @@ -348,6 +359,10 @@ impl<'a> Writer<'a> { definition_objects: 0, definition_symbol_nodes: 0, max_objects: crate::import::MAX_EDITING_OBJECTS, + last_samples: None, + whole_images: HashMap::new(), + shown: vec![], + windowed: None, } } @@ -414,15 +429,21 @@ impl<'a> Writer<'a> { /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are /// given back, except those of the definitions written since, and the images `lines` marks - /// are emptied (the others keep their indices). + /// are emptied (the others keep their indices). An image whose whole copy is among them is no + /// longer written whole ([`Self::whole_images`]). fn give_back(&mut self, c: Charges, lines: &str) { self.objects = c.objects + (self.definition_objects - c.kept_objects); self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + let mut emptied = HashSet::new(); for index in lines.split(PAYLOAD).skip(1).step_by(2) { - if let Some(p) = index.parse::().ok().and_then(|i| self.payloads.get_mut(i)) { + if let Some((i, p)) = index.parse::().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) { *p = Vec::new(); + emptied.insert(i); } } + if !emptied.is_empty() { + self.whole_images.retain(|_, (_, i)| !emptied.contains(i)); + } } /// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written @@ -643,8 +664,17 @@ impl<'a> Writer<'a> { } self.effects(n); self.state(n.locked, !n.visible); - let xy = Xy::of(n); + let mut xy = Xy::of(n); + if masked_warp_pieces(n) { + // The reader gives the pieces no mark, and a knockout group's member then knocks out + // what it paints without its opacity mask: each piece would hide the pieces below it. + // Without knockout, a partly transparent image draws its alpha twice only where the + // pieces overlap. + xy.knockout = Knockout::Off; + } let start = self.body.len(); + // An image's copy written as the part that its opacity mask, among other regions, shows. + let mut mask_window = None; match &n.kind { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), @@ -671,7 +701,25 @@ impl<'a> Writer<'a> { } NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), - NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im), xy), + NodeKind::Image(im) => { + // An opacity mask that clips shows the image within what its art paints only (its + // effects and its symbol instances' art included, as the renderer measures it). + let masked = n + .extras() + .mask + .as_ref() + .filter(|m| m.clip && !m.invert && !m.disabled && self.nest < MAX_NEST) + .and_then(|m| vectorcraft_render::painted_bounds(self.doc, &m.art)); + if let Some(b) = masked { + self.shown.push(b.to_path(0.1)); + } + self.windowed = None; + self.image(&self.doc.shown_image(n, im), xy); + if masked.is_some() { + self.shown.pop(); + mask_window = self.windowed.take(); + } + } NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { if !self.open_group("u") { @@ -737,10 +785,14 @@ impl<'a> Writer<'a> { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); self.block_end = usize::MAX; - self.dictionary(n); + let mask_written = self.dictionary(n); if let Some(b) = block { self.body.push_str(&b); } + // Without its opacity mask, the image shows whole. + if let Some(w) = mask_window.filter(|_| !mask_written) { + self.write_whole(w); + } } // An envelope's plugin `XP` follows its distorted art, after the art's state block. if deform { @@ -773,25 +825,28 @@ impl<'a> Writer<'a> { /// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an /// empty `AIArtName`), has text wrap options ([`Self::text_wrap`]), has an opacity mask /// (`AI9_Mask`, see [`Self::mask`]), or is a symbol's art (its symbol's entries first, see - /// [`Self::define_symbol`]). - fn dictionary(&mut self, n: &Node) { + /// [`Self::define_symbol`]). False when `n` has an opacity mask that is left out: nested too + /// deep, or its art doesn't fit. + fn dictionary(&mut self, n: &Node) -> bool { let name = n.name.as_deref().filter(|s| !s.is_empty()); let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look); let placed = std::mem::take(&mut self.placed); let typed = std::mem::take(&mut self.typed); let bound = self.bound_variables(n, placed, typed); let mut mask = n.extras().mask.as_deref(); + let mut mask_written = true; // Masks nested too deep are left out (an empty mask would hide the object). if mask.is_some() && self.nest >= MAX_NEST { self.warn(DEEP_MASKS); mask = None; + mask_written = false; } let entries = match &self.root_entries { Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e), _ => None, }; if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() && wrap.is_none() { - return; + return mask_written; } let start = self.body.len(); let has_id = name.is_some() || !bound.is_empty(); @@ -817,13 +872,15 @@ impl<'a> Writer<'a> { // A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too. if let Some(m) = mask && !self.mask(m) - && !has_id - && !has_entries - && wrap.is_none() { - return self.body.truncate(start); + if !has_id && !has_entries && wrap.is_none() { + self.body.truncate(start); + return false; + } + mask_written = false; } self.body.push_str("%_;\n%_\n"); + mask_written } /// Text wrap options, as entries of their object's dictionary: the offset in art space's points @@ -866,6 +923,7 @@ impl<'a> Writer<'a> { fn mask(&mut self, m: &OpacityMask) -> bool { let outer = std::mem::take(&mut self.body); let (state, gs, clip) = (self.state, self.gs, self.clip.take()); + let shown_by = std::mem::take(&mut self.shown); let (charges, bound) = (self.charges(), self.bound.len()); (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); // The canvas draws the art's root even when it is hidden, and the reader puts a hidden object @@ -905,6 +963,7 @@ impl<'a> Writer<'a> { self.hidden -= 1; self.nest -= 1; let lines = std::mem::replace(&mut self.body, outer); + self.shown = shown_by; if self.left_out_since(charges) { // The art isn't written, nor are the variable bindings of its objects. self.give_back(charges, &lines); @@ -990,9 +1049,11 @@ impl<'a> Writer<'a> { } } self.clip_paint_part(first, &path, rule, &fills); + self.shown.push(path.to_bezpath()); for c in rest { self.node(c); } + self.shown.pop(); self.clip_paint_part(first, &path, rule, &strokes); self.state(first.locked, !first.visible); let start = self.body.len(); @@ -1041,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); @@ -1667,6 +1731,16 @@ fn clip_path(n: &Node) -> Option<(PathData, FillRule)> { path.subpaths.iter().any(|sp| !sp.anchors.is_empty()).then_some((path, rule)) } +/// Is `n` a knockout group of the pieces of a mesh-warped image cut by opacity masks +/// ([`vectorcraft_doc::live::mesh_warp_image`] with Preserve Shape Using Transparency)? Each piece +/// knocks out its triangle ([`Node::knockout_outline`]); read from the data, a piece knocks out all +/// that its image paints instead. +fn masked_warp_pieces(n: &Node) -> bool { + n.knockout == Knockout::On + && matches!(&n.kind, NodeKind::Group { children, clip: false } + if !children.is_empty() && children.iter().all(|c| c.extras().warp_piece && c.extras().mask.is_some())) +} + /// `text`'s lines on `%_` lines, as a part of the data that readers of the printed form skip (an /// opacity mask's art, a pattern's tile) has them. An image's samples stay as they are after an /// `XI` without `%_`, and `%_%%EndData` ends them, as in the files users sent. Samples hidden diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs index e8a7d10b15..029b37597f 100644 --- a/crates/eps/src/ai_editing/writer/brushes.rs +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -422,7 +422,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state.take(), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), (self.block_start, self.block_end), // The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that // first uses the brush. @@ -459,7 +459,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state, self.gs, - self.clip, + (self.clip, self.shown), (self.block_start, self.block_end), (self.hidden, self.in_look, self.in_symbol), ) = saved; diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 355f5c2709..1d5b9a8c4c 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -2,13 +2,14 @@ //! (see [`crate::ai_editing`] for the form). use std::fmt::Write; +use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{FillLayer, ImageBlob, ImageObject, LinkInfo}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape}; use super::{Writer, Xy}; -use crate::ps; +use crate::{ai_ops, ps}; pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps"; pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps"; @@ -26,8 +27,23 @@ pub(in crate::ai_editing) const PAYLOAD: char = '\u{1}'; /// What starts the line of an image's samples: `XI` and its payload, then `%%EndData`. pub(super) const SAMPLES_LINE: &str = "XI\u{1}"; +/// An image's pixels as the data holds them, and whether it had 16 bits a channel ([`Samples::of`]). +pub(super) type Read = Arc<(Samples, bool)>; + +/// A copy of an image written as the part of it that its regions show ([`Writer::image`]). +pub(super) struct Windowed { + /// Where its raster is in the body. + at: std::ops::Range, + /// The index of its samples in [`Writer::payloads`]. + payload: usize, + /// The image's pixels. + samples: Read, + /// What places the image's whole pixel grid in the document. + xf: Affine, +} + /// An image's pixels as the data holds them. -struct Samples { +pub(super) struct Samples { /// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`. space: &'static str, /// The image type of `XI` (§8.1): 1 gray, 3 RGB, 4 CMYK. @@ -79,6 +95,19 @@ impl Samples { Some((Samples { space, kind, width, height, color, alpha }, deep)) } + /// The pixels of `window` (`[x0, y0, x1, y1]`, within the image) alone. + fn window(&self, window: [u32; 4]) -> Samples { + let [x0, y0, x1, y1] = window; + Samples { + space: self.space, + kind: self.kind, + width: x1 - x0, + height: y1 - y0, + color: ai_ops::window_samples(&self.color, self.width, usize::from(self.kind), window), + alpha: self.alpha.as_ref().map(|a| ai_ops::window_samples(a, self.width, 1, window)), + } + } + /// `XI`'s fourth flag as the files users sent have it: 0 gray, 1 RGB, 2 CMYK, 16 more with an /// alpha channel. fn reserved(&self) -> u8 { @@ -96,10 +125,13 @@ impl Writer<'_> { /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on /// `%_` lines (other than a styled object's copy on the layers, [`Self::styled_object`]) and in /// a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is - /// cut to a clipping path, the image is in a clipping group of that path. + /// cut to a clipping path, the image is in a clipping group of that path; a raster of pixels + /// written whole already is then the part of them that the path shows ([`ai_ops::image_window`]). pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { let doc = self.doc; - let Some((samples, deep)) = doc.image_blob(im).and_then(Samples::of) else { + let blob = doc.image_blob(im); + let read = blob.and_then(|b| self.samples(b)); + let Some((samples, deep)) = read.as_deref() else { let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1))); self.warn(IMAGES); return self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy); @@ -113,7 +145,7 @@ impl Writer<'_> { if !self.take_objects(1 + clipping) { return; } - if deep { + if *deep { self.warn(IMAGE_DEPTH); } let cut = self.clip.take(); @@ -129,13 +161,41 @@ impl Writer<'_> { Some(link) if (self.hidden == 0 || self.styled_copy) && !self.in_symbol => { // Cut to a region, the placed file is in a clipping group. self.placed = cut.is_none(); - self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf); + self.placed_file(link, samples, (im.width.max(1), im.height.max(1)), im.xf); } other => { if other.is_some() { self.warn(LINKS_EMBEDDED); } - self.raster(&samples, im.xf * fit); + // An image written whole already: the part its region shows, placed where it is in + // the image (the pieces of a warped image each show a small part of the same one). + // An object's editable original (on `%_` lines) and an image in an opacity mask's art + // are written whole. + let data = blob.map(|b| &b.bytes); + let written = self.hidden == 0 && data.is_some_and(|d| self.whole_images.contains_key(&(Arc::as_ptr(d) as usize))); + let window = written + .then(|| { + let clips: Vec<_> = self.shown.iter().cloned().chain(cut.iter().map(PathData::to_bezpath)).collect(); + ai_ops::image_window(&clips, im.xf * fit, (samples.width, samples.height)) + }) + .flatten(); + match (window, &read) { + (Some(window), Some(read)) => { + let at = self.body.len(); + self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1])))); + self.windowed = + Some(Windowed { at: at..self.body.len(), payload: self.payloads.len() - 1, samples: read.clone(), xf: im.xf * fit }); + } + _ => { + if let Some(d) = data { + // `raster` adds the samples as the next payload. A copy written whole on + // `%_` lines after an earlier whole copy keeps the earlier one's entry. + let next = self.payloads.len(); + self.whole_images.entry(Arc::as_ptr(d) as usize).or_insert_with(|| (d.clone(), next)); + } + self.raster(samples, im.xf * fit); + } + } } } if let Some(region) = &cut { @@ -232,6 +292,32 @@ impl Writer<'_> { ); } + /// The pixels of `blob` ([`Samples::of`]), read once for images that follow one another with + /// the same data. + fn samples(&mut self, blob: &ImageBlob) -> Option { + if let Some((last, read)) = &self.last_samples + && Arc::ptr_eq(&last.bytes, &blob.bytes) + && last.mime == blob.mime + { + return read.clone(); + } + let read = Samples::of(blob).map(Arc::new); + self.last_samples = Some((blob.clone(), read.clone())); + read + } + + /// The copy `w`, written as a window, written whole in its place, with fresh samples; its + /// window's samples are emptied. + pub(super) fn write_whole(&mut self, w: Windowed) { + let tail = self.body.split_off(w.at.end); + self.body.truncate(w.at.start); + if let Some(p) = self.payloads.get_mut(w.payload) { + *p = Vec::new(); + } + self.raster(&w.samples.0, w.xf); + self.body.push_str(&tail); + } + /// Keep `bytes` to go in the program where the mark this returns is. fn payload(&mut self, bytes: Vec) -> String { self.payloads.push(bytes); diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index f1b52dd91b..d0a90ce86a 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -481,7 +481,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state.take(), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), self.block_end, ); // `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its @@ -505,7 +505,7 @@ impl Writer<'_> { self.hidden -= 1; self.nest -= 1; let art; - (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved; + (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); if self.left_out_since(before) { diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 5a80e50a78..ab9ed6a482 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -750,6 +750,9 @@ impl Writer<'_> { let xy = Xy::of(orig); let was = self.in_look; self.in_look = true; + // The reader draws the look apart from the clipping groups around the object to compare it + // with the object's effects, and keeps the object itself: neither is cut to those groups. + let shown_by = std::mem::take(&mut self.shown); self.line("u"); let mut look = n.clone(); (look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = @@ -800,6 +803,7 @@ impl Writer<'_> { } self.dictionary(orig); self.hidden -= 1; + self.shown = shown_by; let part = std::mem::replace(&mut self.body, saved); hide(&part, &mut self.body); self.block_end = usize::MAX; diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 86d7c4694d..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 @@ -245,7 +246,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas, self.instance_space), self.state.replace((false, false)), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), (self.block_start, self.block_end), (std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)), self.root_entries.take(), @@ -302,7 +303,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas, self.instance_space), self.state, self.gs, - self.clip, + (self.clip, self.shown), (self.block_start, self.block_end), (self.in_symbol, self.hidden), self.root_entries, diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 6bc3c63b09..885af725b7 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -204,7 +204,9 @@ pub(in crate::ai_editing) fn holds(n: &Node, t: &TextObject) -> bool { shape(t).as_ref().and_then(segments).is_some_and(|s| !(open && closed(&s))) } }; - // Area type that flows around wrap objects: the data doesn't hold their wrap settings. + // Area type with wrap shapes: the text document doesn't hold them. The reader works them out + // again from the art above the type, which can wrap it differently (a live object written as + // its drawn pieces) or not at all. shape_ok && t.wrap.is_empty() && t.ruby.is_empty() @@ -962,6 +964,7 @@ impl TextDoc { matches!(t.kind, TextKind::Area { .. }) && t.vertical == vertical && writable(n.id) + // The text document doesn't hold wrap shapes (see `holds`). && t.wrap.is_empty() && plain(n) && t.ruby.is_empty() diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 3b2bdd88d8..6b1374cd83 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -7,7 +7,7 @@ use std::fmt::Write; use vectorcraft_color::{Color, Gradient}; use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer}; -use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath}; +use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Shape, SubPath}; use crate::ps; @@ -131,6 +131,55 @@ pub(crate) fn average(g: &Gradient) -> Color { Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n)) } +/// The pixels an image's part keeps past the edge of the region that shows it, on each side: as +/// many as smoothing reads from beside a pixel, and one more. +const WINDOW_MARGIN: f64 = 2.0; + +/// The part of an image of `w` × `h` pixels, which `to_doc` places in the document, that regions +/// `clips` (each in the document, the image cut to all of them) show, with [`WINDOW_MARGIN`] pixels +/// more on each side: `[x0, y0, x1, y1]`, in pixels from the top left. One pixel of the image, the +/// nearest to where the regions are, when they show none of it. `None` when that part has half the +/// image's pixels or more, or when `to_doc` can't be undone; the whole image is written then. +pub(crate) fn image_window(clips: &[kurbo::BezPath], to_doc: Affine, (w, h): (u32, u32)) -> Option<[u32; 4]> { + if clips.is_empty() || w == 0 || h == 0 || !to_doc.is_finite() || to_doc.determinant().abs() < 1e-12 { + return None; + } + let to_pixels = to_doc.inverse(); + let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, f64::from(w), f64::from(h)); + for clip in clips { + let b = (to_pixels * clip.clone()).bounding_box(); + if !b.is_finite() { + return None; + } + x0 = f64::max(x0, b.x0.floor() - WINDOW_MARGIN); + y0 = f64::max(y0, b.y0.floor() - WINDOW_MARGIN); + x1 = f64::min(x1, b.x1.ceil() + WINDOW_MARGIN); + y1 = f64::min(y1, b.y1.ceil() + WINDOW_MARGIN); + } + if x0 >= x1 || y0 >= y1 { + let (x, y) = ((x0 as u32).min(w - 1), (y0 as u32).min(h - 1)); + return Some([x, y, x + 1, y + 1]); + } + if 2.0 * (x1 - x0) * (y1 - y0) >= f64::from(w) * f64::from(h) { + return None; + } + Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) +} + +/// The rows `y0..y1` and columns `x0..x1` (`window`) of `samples`, `w` pixels a row of `channels` +/// bytes each, rows from the top. +pub(crate) fn window_samples(samples: &[u8], w: u32, channels: usize, [x0, y0, x1, y1]: [u32; 4]) -> Vec { + let row = w as usize * channels; + let (from, to) = (x0 as usize * channels, x1 as usize * channels); + let mut out = Vec::with_capacity((y1 - y0) as usize * (to - from)); + for y in y0 as usize..y1 as usize { + if let Some(part) = samples.get(y * row + from..y * row + to) { + out.extend_from_slice(part); + } + } + out +} + /// The stroke's width, cap, join, miter limit and dashes: `w J j M d`. pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) { let cap = match st.cap { diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index bdb3b9cf50..c791f7c459 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -94,7 +94,7 @@ pub(crate) use ai::{ }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; -pub(crate) use native::{MAX_TEXT, scale_document}; +pub(crate) use native::{MAX_TEXT, scale_document, scale_symbols}; pub use text::family_style; pub(crate) use text::{document_font, postscript_face}; diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 1180e42fa9..fd28e42e47 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -18,7 +18,8 @@ //! 1 pt draws it at its own size), the brush by name (`/GObjRef : (name) (plug-in) //! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`, //! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the -//! page). +//! page). Samples of the pen may follow a path's own lines on `%_` lines (`(hex) Xt n XD`). They +//! aren't read, so such a path keeps its drawn look when a setting varies with the pen. //! - A path with an Art brush is a stroke in its art style too: the filter //! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across //! the path), the brush by name, and its settings for this path as entries: `Scaling`, diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 9d66bd2d17..0cf1c64a89 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -215,6 +215,9 @@ pub struct Structure { pub text_patterns: HashMap, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, + /// The data's symbols are at their own size, whatever its scale: VectorCraft wrote it (its + /// `%%Creator` is [`crate::ai_editing::CREATOR_TEXT`]). + pub symbols_own_size: bool, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an /// id two objects have. pub xml_ids: HashMap>, @@ -599,6 +602,10 @@ struct Reader<'a> { compound_groups: HashSet, /// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look. text_looks: HashSet, + /// Objects followed by samples of the pen (`(…) Xt n XD`), which aren't read: such an object + /// whose style has a Calligraphic brush that varies with the pen keeps its drawn look (see + /// [`Self::style_marker`]). + pen_samples: HashSet, /// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line. mesh: Option<(super::mesh::MeshReader, bool)>, /// The art styles (`%AI9_BeginArtStyles`), by name. @@ -644,6 +651,8 @@ struct Reader<'a> { page_art: bool, /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. canvas_scale: f64, + /// [`Structure::symbols_own_size`], from the header's `%%Creator`. + symbols_own_size: bool, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). ruler_units: Option, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). @@ -791,6 +800,7 @@ impl<'a> Reader<'a> { ruler_guides: HashMap::new(), compound_groups: HashSet::new(), text_looks: HashSet::new(), + pen_samples: HashSet::new(), group_ended: false, mesh_ended: false, layer_state: None, @@ -818,6 +828,7 @@ impl<'a> Reader<'a> { large_canvas: None, page_art: false, canvas_scale: 1.0, + symbols_own_size: false, ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index f86ee6603b..0fe6b9da87 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -79,6 +79,10 @@ impl<'a> Reader<'a> { } return Ok(()); } + if let Some(v) = bytes.strip_prefix(b"%Creator:") { + self.symbols_own_size = v.trim_ascii() == crate::ai_editing::CREATOR_TEXT.as_bytes(); + return Ok(()); + } if bytes == b"%EndComments" { self.header_done = true; } diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index a7451c8b99..ba6a8bd2de 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -467,6 +467,10 @@ pub struct Symbols { /// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds /// them to a document read from the layers. pub swatches: Vec, + /// The data's declared large canvas scale (1 when it declares none), not applied to the art. + pub canvas_scale: f64, + /// [`Structure::symbols_own_size`]. + pub own_size: bool, } /// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library @@ -529,7 +533,7 @@ pub fn symbols(data: &[u8], names: &mut obj::Names) -> Option { .chain(art_names(symbols.iter().map(|(s, _)| &*s.art))) .collect(); names.absorb_among(std::mem::take(&mut r.names), &used); - Some(Symbols { symbols, images, swatches }) + Some(Symbols { symbols, images, swatches, canvas_scale: r.canvas_scale, own_size: r.symbols_own_size }) } /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and @@ -1147,7 +1151,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); @@ -1194,6 +1198,11 @@ impl<'a> Reader<'a> { drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); + // A Large Canvas copy's symbols are at its art's scale too ([`Self::ensure_space`]), except + // in data VectorCraft wrote. + if let Some(s) = self.large_canvas.filter(|s| *s != 1.0 && !self.symbols_own_size) { + crate::import::scale_symbols(&mut doc, s); + } // The active artboard, when it is not the first (the default). let active = self.active_board.filter(|i| *i > 0 && *i < self.artboards.len()); let boards: Vec = if self.artboards.is_empty() { @@ -1374,6 +1383,7 @@ impl<'a> Reader<'a> { styles_read, text_patterns, canvas_scale: self.canvas_scale, + symbols_own_size: self.symbols_own_size, xml_ids: self.xml_ids, bindings, bindings_skipped, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index f10682b592..8c032d5d83 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -89,6 +89,18 @@ impl<'a> Reader<'a> { } } + /// `(…) Xt n XD`: samples of the pen along the object before it. They aren't read, so the object + /// keeps its drawn look when its style has a Calligraphic brush with a setting that varies with + /// the pen ([`Self::style_marker`]). + fn pen_samples_op(&mut self) { + if !self.after_object || self.pen_samples.len() >= MAX_NODES { + return; + } + if let Some(n) = self.frames.last().and_then(|f| f.children.last()) { + self.pen_samples.insert(n.id); + } + } + /// `/identifier (string) XT` (version 7 specification §7): a tag on the object before it. The /// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in /// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with @@ -418,6 +430,7 @@ impl<'a> Reader<'a> { self.end_envelope(); } } + "XD" => self.pen_samples_op(), _ if IGNORED.contains(&w) => {} // Before the art, the setup runs procedures of the printing format. _ if self.frames.is_empty() => {} @@ -559,9 +572,9 @@ impl<'a> Reader<'a> { return; } self.image_tail = false; - // An object's tags (`XT`, and a graph's `Go`) follow it one after another, and an image's - // follow the `XH` that ends its data. - if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go") { + // An object's tags (`XT`, and a graph's `Go`) and samples of the pen (`Xt`, `XD`) follow it + // one after another, and an image's tags follow the `XH` that ends its data. + if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go" | "Xt" | "XD") { return; } self.after_object = false; @@ -688,6 +701,8 @@ impl<'a> Reader<'a> { /// drawn look before it (all on `%_` lines inside another such object). Other markers end what /// follows an object. fn style_marker(&mut self, vals: &[V], hidden: bool) { + use crate::import::ai::brush::PathBrush; + use crate::import::ai::style::{ArtStyle, StyleItem}; let code = Self::nums(vals).last().copied().unwrap_or(0.0); let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); @@ -754,8 +769,20 @@ impl<'a> Reader<'a> { } else { look }; - // A style read here: the object, painted as it says. - let appearance = self.styles.get(&style).and_then(|st| { + // A style read here: the object, painted as it says. An object with samples of the pen + // keeps its drawn look when its style has a Calligraphic brush with a setting that varies + // with the pen: the samples aren't read, and the brush draws such a stroke at the pen's + // middle values. + let sampled = self.pen_samples.contains(&object.id); + let follows_pen = |st: &ArtStyle| { + st.items.iter().any(|i| match i { + StyleItem::Stroke { brush: Some((_, PathBrush::Calligraphic(c))), .. } => { + c.modes().iter().zip(c.variation).any(|(m, v)| m.follows_pen() && v != 0.0) + } + _ => false, + }) + }; + let appearance = self.styles.get(&style).filter(|st| !(sampled && follows_pen(st))).and_then(|st| { styled( object, look, @@ -1296,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; @@ -1314,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); } @@ -1324,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)] @@ -1373,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/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 83a838a6cb..b6e908c2e1 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -386,9 +386,8 @@ pub(super) const IGNORED: &[&str] = &[ // Path resolution (`Ar`, §12): how finely curves print. "Ar", "Xs", + // The samples of the pen that `XD` notes on the object before them (`Reader::pen_samples`). "Xt", - // Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path. - "XD", "Xi", "XI", "LB2", diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 891c058bb6..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 })), @@ -1445,13 +1490,38 @@ fn offset_stroke(f: &Filter) -> Result, ()> { stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside }) } +/// The entries a Calligraphic brush's filter may hold, with the settings [`calligraphic_stroke`] +/// reads. Any other entry keeps the drawn look. +const CALLIGRAPHIC_ENTRIES: [&str; 14] = [ + "StrokeStyle", + "DisplayString", + "Brush", + "Diameter", + "DiameterVariation", + "DiameterDependsOn", + "Roundness", + "RoundnessVariation", + "RoundnessDependsOn", + "Angle", + "AngleVariation", + "AngleDependsOn", + "AngleRelativeTo", + "Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files +]; + /// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a -/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle -/// relative to the path isn't read. +/// color, names no Calligraphic brush, holds an entry or a width that isn't read, or its settings +/// aren't all there as numbers. An angle relative to the path isn't read. fn calligraphic_stroke(f: &Filter) -> Option { let real = |key: &str| entry(f, key, Kind::Real).ok().flatten(); let code = |key: &str| entry(f, key, Kind::Int).ok().flatten(); - if !f.paints.0 || f.gradient || f.pattern.is_some() || f.repeated { + if !f.paints.0 + || f.gradient + || f.pattern.is_some() + || f.repeated + || !f.arrays.is_empty() + || !f.kinds.keys().all(|k| CALLIGRAPHIC_ENTRIES.contains(&k.as_str())) + { return None; } if entry(f, "AngleRelativeTo", Kind::Int).ok()?.unwrap_or(0.0) != 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/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 7a821bf184..d160248a2d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -10304,20 +10304,86 @@ fn a_path_with_a_calligraphic_brush_comes_in_brushed() { let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &a[0].path_data().unwrap().to_bezpath(), st); let height = pieces.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.height()).fold(0.0, f64::max); assert!(height > 40.0 && height < 46.0, "the line's rise and about half the nib's 3 pt: {height}"); + // A filter with the version entry an effect's filter may carry still comes in brushed. + let versioned = style.replace("(Brush) ,\n", "(Brush) ,\n15 /Int (Adobe Effect Expand Before Version) ,\n"); + let a = art(&read_ok(&format!("{def}{versioned}{}", layer(&brushed_path()))).doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}"); +} + +/// Samples of the pen along a path (`(…) Xt n XD`), as they follow its own lines. +const PEN_SAMPLES: &str = "%_(00FE64FE) Xt 0 XD\n%_(00006400) Xt 1 XD\n%_(00986498) Xt 2 XD\n%_(009E649E) Xt 3 XD\n%_(007F647F) Xt 4 XD\n"; + +/// A path with a Calligraphic brush that varies with the pen, followed by samples of the pen, comes +/// in as its drawn look alone, with the note that says so: the samples aren't read, and the brush +/// would draw it at the pen's middle values. Its own path doesn't come in beside the look. A brush +/// that doesn't follow the pen draws such a path the same without the samples, so it comes in +/// brushed. +#[test] +fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() { + // GUARDS: the path came in beside its look, unpainted, and the note was missing. + let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D); + let style = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [2, 5, 6], 0); + let path = brushed_path().replace("%_n\n", &format!("%_n\n{PEN_SAMPLES}")); + let r = read_ok(&format!("{def}{style}{}", layer(&path))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + // The samples belong to the path before them: a path after them is read as it was. + let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc); + assert_eq!(a.len(), 2, "{a:#?}"); + assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}"); + // Samples with no object before them in the layer mark nothing: the path after them is read. + let first = format!("{PEN_SAMPLES}{}", brushed_path()); + let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, brushed: {a:#?}"); + // A brush whose modes are Fixed and Random, one whose pen modes vary by 0, and an Art brush + // don't follow the pen: the path comes in brushed. + let fixed_def = plugin_object(CALLIGRAPHIC, "Six D", &SIX_D.replace(" 2 5 6 0", " 0 1 0 0")); + let fixed = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [0, 1, 0], 0); + let even_def = plugin_object(CALLIGRAPHIC, "Six D", "1 6 6 40 40 30 30 2 5 6 0"); + let even = brushed_style("Six D", 0.5, [(6.0, 0.0), (40.0, 0.0), (30.0, 0.0)], [2, 5, 6], 0); + for (why, def, style) in [("Fixed and Random", fixed_def, fixed), ("no variation", even_def, even)] { + let r = read_ok(&format!("{def}{style}{}", layer(&path))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{why}: {a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, brushed: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings); + } + let ribbon = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let a = art(&read_ok(&format!("{ribbon}{}{}", art_style("Ribbon", 2.0, &STRETCHED, ""), layer(&path))).doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{a:#?}"); + // A style with no brush doesn't draw the samples: the path takes it as it would without them. + let square = styled_square("Blue").replace("f\n1 (Blue) XW", &format!("f\n{PEN_SAMPLES}1 (Blue) XW")); + let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]), layer(&square))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path with its style: {a:#?}"); + assert_eq!(a[0].extras().graphic_style, Some(1), "linked to its style"); } /// A brushed path keeps its drawn look when its stroke can't be read as the brush: the object's /// own settings differ from the brush's (Options of Selected Object), its angle follows the path, -/// or the file defines no brush of its name. +/// its filter holds an entry or a variable width that isn't read, or the file defines no brush of +/// its name. #[test] fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D); let mut own = SIX_D_SETTINGS; own[0] = (9.0, 2.0); + let plain = brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0); + let with = |more: &str| plain.replace("(Brush) ,\n", &format!("(Brush) ,\n{more}")); + let widths = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ + /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"; let cases = [ ("its own settings", def.clone(), brushed_style("Six D", 1.0, own, [2, 5, 6], 0)), ("its own modes", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 4], 0)), ("an angle on the path", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 1)), + ("an entry that isn't read", def.clone(), with("1 /Real (Unread) ,\n")), + ("a variable width", def.clone(), with(widths)), ("no such brush", def, brushed_style("Other", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0)), ]; for (why, def, style) in cases { diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 8d01b141d0..28994527a7 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -26,8 +26,8 @@ use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - CharStyle, HyphenationOptions, JustificationOptions, Justify, LineCap, LineJoin, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, - PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, + Burasagari, CharStyle, HyphenationOptions, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LineCap, LineJoin, Node, NodeId, NodeKind, + ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, }; use vectorcraft_geom::{Affine, PathData, Point, Rect}; @@ -405,6 +405,42 @@ struct Para { tabs: Vec, /// The paragraph has more than [`MAX_TABS`] stops: those past them were left out. tabs_truncated: bool, + /// Key 27: the Kinsoku set, `/nil` for None, else the index of one of the document's sets + /// ([`Texts::kinsoku_sets`]). `None`: the default. + kinsoku: Option, + /// Key 27 names a set of the document that isn't Hard or Soft: the paragraph takes the set it + /// would have without it. + kinsoku_custom: bool, + /// Key 25: Kinsoku Shori Type, 0 Push In First, 1 Push Out First, 2 Push Out Only. `None`: the + /// default. + kinsoku_type: Option, + /// Key 24: Burasagari, 0 None, 1 Regular, 2 Force. `None`: the default. + burasagari: Option, + /// Key 23: Bunri-Kinshi. + bunri_kinshi: bool, +} + +/// The name of the document's predefined Hard Kinsoku set (key 0 of its entry in the document's +/// Kinsoku sets). +const KINSOKU_HARD: &str = "PhotoshopKinsokuHard"; +/// The name of the document's predefined Soft Kinsoku set. +const KINSOKU_SOFT: &str = "PhotoshopKinsokuSoft"; +/// The warning when a paragraph's Kinsoku set is one VectorCraft doesn't have. +pub(crate) const KINSOKU_SETS_LEFT_OUT: &str = "custom Kinsoku sets were left out: their paragraphs take the set their paragraph style has"; + +/// The Kinsoku sets of text document `doc` (the list at key 0 of key 4 of its resources), in +/// order, each by its name (key 0 of the set): Hard or Soft, `None` for any other set. Those past +/// [`MAX_SHEETS`] are left out. +fn kinsoku_sets(doc: &Val) -> Vec> { + let list = doc.get("0").and_then(|d| d.get("4")).and_then(|s| s.get("0")).map(Val::list).unwrap_or_default(); + list.iter() + .take(MAX_SHEETS) + .map(|s| match s.get("0").and_then(|r| r.get("0")).and_then(Val::str) { + Some(KINSOKU_HARD) => Some(Kinsoku::Hard), + Some(KINSOKU_SOFT) => Some(Kinsoku::Soft), + _ => None, + }) + .collect() } /// Most tab stops read for one paragraph. @@ -542,6 +578,9 @@ pub(super) struct Texts { /// The patterns that paint characters, by the name of the art style that gives each /// ([`Self::with_patterns`]). patterns: HashMap, + /// The document's Kinsoku sets (key 4 of its resources), in order, each by its name (key 0): + /// Hard or Soft, `None` for any other set. + kinsoku_sets: Vec>, } impl Texts { @@ -611,6 +650,7 @@ impl Texts { para_sheets: sheets("6"), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), patterns: HashMap::new(), + kinsoku_sets: kinsoku_sets(&doc), }) } @@ -749,7 +789,37 @@ impl Texts { chain.extend(self.para_sheets.first().and_then(|s| s.get("5"))); let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.para_defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num).filter(|v| v.abs() < 1e5).unwrap_or(0.0); + // The East Asian settings: the first value along the chain that can be read, so one that + // can't be read takes the paragraph style's. + let levels = || chain.iter().copied().chain(std::iter::once(&self.para_defaults)); + let choice = |k: &str| { + levels().find_map(|s| match s.get(k) { + Some(Val::Num(v)) if *v == 0.0 || *v == 1.0 || *v == 2.0 => Some(*v as u8), + _ => None, + }) + }; + let set = |v: &Val| match v { + Val::Name(n) if n == "nil" => Some(Some(Kinsoku::None)), + Val::Num(i) if *i >= 0.0 && i.fract() == 0.0 => self.kinsoku_sets.get(*i as usize).copied(), + _ => None, + }; Para { + kinsoku: levels().find_map(|s| s.get("27").and_then(set).flatten()), + kinsoku_custom: levels().find_map(|s| s.get("27").and_then(set)).is_some_and(|k| k.is_none()), + kinsoku_type: choice("25").map(|v| match v { + 0 => KinsokuShoriType::PushInFirst, + 1 => KinsokuShoriType::PushOutFirst, + _ => KinsokuShoriType::PushOutOnly, + }), + burasagari: choice("24").map(|v| match v { + 0 => Burasagari::None, + 1 => Burasagari::Standard, + _ => Burasagari::Forced, + }), + bunri_kinshi: levels().find_map(|s| match s.get("23") { + Some(Val::Bool(b)) => Some(*b), + _ => None, + }) == Some(true), justify: pick("0").and_then(Val::num).map_or(0, |v| v.clamp(0.0, 6.0) as u8), rtl: pick("33").and_then(Val::num) == Some(1.0), first_indent: num("1"), @@ -1264,6 +1334,11 @@ impl Story { self.tabs_left_out.get() } + /// Does a paragraph have a Kinsoku set VectorCraft doesn't have ([`Para::kinsoku_custom`])? + pub(super) fn kinsoku_sets_left_out(&self) -> bool { + self.paras.iter().any(|(_, p)| p.kinsoku_custom) + } + /// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and /// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its /// page draws it: its first baseline is fixed where the file has it. The font's ascent below the @@ -1425,6 +1500,10 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { auto_leading: p.auto_leading.map_or(JustificationOptions::default().auto_leading, |a| (a * 1e6).round() / 1e4), ..JustificationOptions::default() }, + kinsoku: p.kinsoku.unwrap_or_default(), + kinsoku_type: p.kinsoku_type.unwrap_or_default(), + burasagari: p.burasagari.unwrap_or_default(), + bunri_kinshi: p.bunri_kinshi, ..ParaStyle::default() } } @@ -1496,6 +1575,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. @@ -1520,6 +1600,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading; assert_eq!(auto(b""), JustificationOptions::default().auto_leading); @@ -1543,6 +1624,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation; let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true }; @@ -1561,12 +1643,93 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; assert_eq!(own(b""), d); assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d); } + /// The document's Kinsoku sets are known by their names, in order: the predefined Hard and + /// Soft sets, in either string form, and any other set as one VectorCraft doesn't have. + #[test] + fn the_documents_kinsoku_sets_are_known_by_name() { + let utf16: Vec = b"(\xfe\xff".iter().copied().chain(KINSOKU_SOFT.bytes().flat_map(|c| [0, c])).chain(*b")").collect(); + let doc = read( + &[ + b"/0 << /4 << /0 [ << /0 << /0 (PhotoshopKinsokuHard) /5 << /4 1 >> >> >> << /0 << /0 ".as_slice(), + &utf16, + b" >> >> << /0 << /0 (My Set) >> >> << /0 << >> >> ] >> >>", + ] + .concat(), + ) + .unwrap(); + assert_eq!(kinsoku_sets(&doc), [Some(Kinsoku::Hard), Some(Kinsoku::Soft), None, None]); + assert_eq!(kinsoku_sets(&read(b"/0 << >>").unwrap()), []); + } + + /// Keys 23 to 27 are the paragraph's East Asian settings: Bunri-Kinshi (23), Burasagari (24: + /// 0 None, 1 Regular, 2 Force), Kinsoku Shori Type (25: 0 Push In First, 1 Push Out First, 2 + /// Push Out Only) and the Kinsoku set (27: `/nil` for None, else the index of one of the + /// document's sets). A value that can't be read, and a set VectorCraft doesn't have, take the + /// paragraph style's, or else the document's. + #[test] + fn a_paragraph_keeps_its_east_asian_settings() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/27 0 /25 2 /24 0 /23 false").unwrap(), + char_sheets: vec![], + // [Normal Paragraph Style] has the Soft set; the second style has Force burasagari. + para_sheets: vec![read(b"/5 << /27 1 >>").unwrap(), read(b"/5 << /24 2 /23 true >>").unwrap()], + frames: vec![], + patterns: HashMap::new(), + kinsoku_sets: vec![Some(Kinsoku::Hard), Some(Kinsoku::Soft), None], + }; + let para = |run: &[u8]| texts.para(Some(&read(run).unwrap())); + let settings = |run: &[u8]| { + let p = para_style(¶(run), 1.0); + (p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi) + }; + let normal = (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::None, false); + assert_eq!(settings(b"/5 << >>"), normal); + assert_eq!(settings(b"/5 << /27 /nil /25 0 /24 1 /23 true >>"), (Kinsoku::None, KinsokuShoriType::PushInFirst, Burasagari::Standard, true)); + assert_eq!(settings(b"/5 << /27 0 /25 1 /24 2 >>"), (Kinsoku::Hard, KinsokuShoriType::PushOutFirst, Burasagari::Forced, false)); + // The paragraph style it is based on (key 6), then [Normal Paragraph Style]. + assert_eq!(settings(b"/5 << >> /6 1"), (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, true)); + assert_eq!(settings(b"/5 << /23 false >> /6 1"), (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, false)); + // A set VectorCraft doesn't have takes the paragraph style's, and the paragraph says so. + assert_eq!(settings(b"/5 << /27 2 >>"), normal); + assert!(para(b"/5 << /27 2 >>").kinsoku_custom); + assert!(!para(b"/5 << /27 0 >>").kinsoku_custom && !para(b"/5 << >>").kinsoku_custom); + // The story warns of it (the importer's warning). + assert!(story("あ\r", &[0.0], ¶(b"/5 << /27 2 >>")).kinsoku_sets_left_out()); + assert!(!story("あ\r", &[0.0], ¶(b"/5 << /27 1 >>")).kinsoku_sets_left_out()); + for unreadable in [ + b"/5 << /27 3 >>".as_slice(), + b"/5 << /27 -1 >>", + b"/5 << /27 0.5 >>", + b"/5 << /27 (PhotoshopKinsokuHard) >>", + b"/5 << /27 /Hard >>", + b"/5 << /25 3 >>", + b"/5 << /25 1.5 >>", + b"/5 << /25 -1 >>", + b"/5 << /24 /nil >>", + b"/5 << /24 true >>", + b"/5 << /23 1 >>", + b"/5 << /23 /nil >>", + ] { + assert_eq!(settings(unreadable), normal, "{}", String::from_utf8_lossy(unreadable)); + assert!(!para(unreadable).kinsoku_custom, "{}", String::from_utf8_lossy(unreadable)); + } + // Without any of the keys, VectorCraft's defaults. + let bare = Texts { para_defaults: Val::Dict(BTreeMap::new()), para_sheets: vec![], kinsoku_sets: vec![], ..texts }; + let p = para_style(&bare.para(Some(&read(b"/5 << >>").unwrap())), 1.0); + let d = ParaStyle::default(); + assert_eq!((p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi), (d.kinsoku, d.kinsoku_type, d.burasagari, d.bunri_kinshi)); + } + /// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key /// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is /// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13. @@ -1581,6 +1744,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let tabs = |own: &[u8], scale: f64| { para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) @@ -1648,6 +1812,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], } .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); @@ -1696,6 +1861,7 @@ mod tests { para_sheets: vec![], frames: cases.iter().map(|c| frame(c)).collect(), patterns: HashMap::new(), + kinsoku_sets: vec![], }; let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); @@ -1714,6 +1880,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); @@ -1738,6 +1905,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman; assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults"); @@ -1769,6 +1937,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let stroke = |run: &[u8]| { let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY); @@ -1811,6 +1980,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], } .with_patterns(patterns) } @@ -1894,6 +2064,7 @@ mod tests { para_sheets: vec![], frames: vec![read(b"/0 << /0 [ 0 0 ] /2 << /0 0 >> >>").unwrap()], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let text = |kerns: &[u8]| -> TextObject { let node = texts(kerns).story(0).unwrap().node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap(); @@ -1961,6 +2132,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); @@ -2009,6 +2181,7 @@ mod tests { para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], patterns: HashMap::new(), + kinsoku_sets: vec![], }; texts.frame(0) } @@ -2667,6 +2840,7 @@ mod tests { para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], patterns: HashMap::new(), + kinsoku_sets: vec![], }; texts.frame(0).unwrap() } diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index 4c00486de9..18498352f7 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -70,6 +70,8 @@ pub(super) struct CidChar { /// The base-font code or glyph name a `bfchar` or `bfrange` mapping gives it, instead of a /// CID. pub base: Option, + /// The value of its code (`<8001>` is 32769). + pub value: u32, } impl CMap { @@ -146,12 +148,12 @@ impl CMap { work += at.map_or(self.bases.len(), |k| k + 1) as u64; let base = at.and_then(|k| self.bases.get(k)).and_then(|m| Some((m.4, base_of(&m.3, c - m.1)?))); out.push(match (matched.is_some(), find(&self.cids, &mut work), base) { - (true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None }, - (true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base) }, + (true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None, value: c }, + (true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base), value: c }, // No character mapping: the notdef mapping's CID, else font 0's CID 0. _ => match find(&self.notdefs, &mut work) { - Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None }, - _ => CidChar { font: 0, cid: 0, notdef: None, base: None }, + Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None, value: c }, + _ => CidChar { font: 0, cid: 0, notdef: None, base: None, value: c }, }, }); i += n; diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index eb5e815e80..5acaba34a4 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -47,9 +47,9 @@ mod layers; mod plan; mod slots; -pub(crate) use build::scale_document; #[cfg(test)] use build::{Frame, MADE, prune_unseen}; +pub(crate) use build::{scale_document, scale_symbols}; #[cfg(test)] use compare::{ MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 1b212194a1..c469f02cd4 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -10,7 +10,7 @@ use super::slots::{ use super::{ Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, VariableKind, Vec2, ai, }; -use vectorcraft_doc::PlacedDocument; +use vectorcraft_doc::{PlacedDocument, Symbol}; /// Which box of the editing copy is the page. #[derive(Clone, Copy)] @@ -83,12 +83,11 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64, names: ai: }; let in_units = |r: Option, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0); let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k))); - let styles = structure.styles_read; + let (styles, own_size) = (structure.styles_read, structure.symbols_own_size); let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; if let Some(k) = scale { // The graphic styles read from the file are drawn in its units too. - scale_document(&mut doc, k, styles); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, k, styles, own_size); } Ok((doc, notes)) } @@ -108,12 +107,11 @@ pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f6 let Some(scale) = alone_scale(&structure, data, pages, unit) else { return Err(Box::new(structure)); }; - let styles = structure.styles_read; + let (styles, own_size) = (structure.styles_read, structure.symbols_own_size); let built = build_from(structure, data, None, Page::Artboard, false, false, 1.0).map(|(mut doc, notes)| { if scale != 1.0 { // The graphic styles read from the file are drawn in its units too. - scale_document(&mut doc, scale, styles); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, scale, styles, own_size); } (doc, notes) }); @@ -268,6 +266,7 @@ pub(super) fn build_weighing( styles_read, text_patterns, canvas_scale, + symbols_own_size, xml_ids, bindings, bindings_skipped, @@ -577,8 +576,7 @@ pub(super) fn build_weighing( bind_variables(&mut doc, &bindings, bindings_skipped, &xml_ids, &filled, &mut notes); placed_documents(&mut doc, &placed_pages); if small.is_some() { - scale_document(&mut doc, unit, styles_read); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, unit, styles_read, symbols_own_size); } Ok((doc, notes)) } @@ -1916,7 +1914,7 @@ fn drop_guides_off_boards(doc: &mut Document) { /// styles. pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let a = Affine::scale(k); - let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; + let scaling = document_scaling(); for layer in &mut doc.layers { let layer = Arc::make_mut(layer); layer.transform(a, scaling); @@ -1935,6 +1933,101 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { } } +/// What [`scale_document`] and [`scale_symbols`] scale with the art: strokes, effects and pattern +/// tiles. +fn document_scaling() -> Scaling { + Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() } +} + +/// A document read in units of `1/k` points drawn at its size: [`scale_document`], and its wrapped +/// type laid out again. Unless `own_size` (data VectorCraft wrote, whose symbols and opacity masks +/// that don't move with their object are at their own size), also the art of those masks on the +/// layers and its symbols ([`scale_symbols`]). +fn scale_read_document(doc: &mut Document, k: f64, styles: bool, own_size: bool) { + scale_document(doc, k, styles); + if !own_size { + let a = Affine::scale(k); + for layer in &mut doc.layers { + transform_unlinked_masks(Arc::make_mut(layer), a, document_scaling()); + } + scale_symbols(doc, k); + } + relayout_wrapped(doc); +} + +/// Draw the art of `doc`'s symbols `k` times as large ([`scale_symbol_art`]), with every instance +/// on the layers and in a pattern's tile (opacity masks included) placed by its transform times +/// `1/k`, so it draws where and as large as before, with the symbol's strokes and effects `k` +/// times as wide. +pub(crate) fn scale_symbols(doc: &mut Document, k: f64) { + if !(k.is_finite() && k > 0.0) || k == 1.0 || doc.symbols.is_empty() { + return; + } + scale_symbol_art(&mut doc.symbols, k); + let back = Affine::scale(1.0 / k); + for n in doc.layers.iter_mut().chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { + place_instances_by(n, back); + } +} + +/// Draw the art of `symbols` `k` times as large, from each symbol's origin, with its strokes, +/// effects and pattern tiles, as [`scale_document`] draws the layers. An instance in the art keeps +/// its own scale and turn: it is placed by its transform times `1/k`. +pub(crate) fn scale_symbol_art(symbols: &mut [Symbol], k: f64) { + if !(k.is_finite() && k > 0.0) || k == 1.0 { + return; + } + let (a, back) = (Affine::scale(k), Affine::scale(1.0 / k)); + for s in symbols { + let art = Arc::make_mut(&mut s.art); + art.transform(a, document_scaling()); + transform_unlinked_masks(art, a, document_scaling()); + scale_wrap_offsets(art, k); + place_instances_by(&mut s.art, back); + } +} + +/// The art of the opacity masks in `n` that don't move with their object, which +/// [`Node::transform`] leaves where it is, transformed by `a` with `scaling` as `n` was: a change of +/// units moves them too. +fn transform_unlinked_masks(n: &mut Node, a: Affine, scaling: Scaling) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + let linked = m.linked; + let art = Arc::make_mut(&mut m.art); + if !linked { + art.transform(a, scaling); + } + transform_unlinked_masks(art, a, scaling); + } + if let Some(children) = n.children_mut() { + for c in children { + transform_unlinked_masks(Arc::make_mut(c), a, scaling); + } + } +} + +/// Every symbol instance in `n` (opacity masks included) placed by its transform times `m`. Only +/// the objects on the way to an instance are copied. +fn place_instances_by(n: &mut Arc, m: Affine) { + let mut holds = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. })); + if !holds { + return; + } + let n = Arc::make_mut(n); + if let NodeKind::SymbolInstance { xf, .. } = &mut n.kind { + *xf *= m; + } + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + place_instances_by(&mut mask.art, m); + } + if let Some(children) = n.children_mut() { + for c in children { + place_instances_by(c, m); + } + } +} + /// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points /// they may be). fn scale_wrap_offsets(n: &mut Node, k: f64) { @@ -2256,6 +2349,85 @@ mod candidate_tests { assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]); } + /// Symbols drawn ten times as large keep every instance where and as large as it was (on a layer, + /// in an opacity mask's art, linked or not, and in a pattern's tile), and an instance in a + /// symbol's art keeps its own scale and turn, moved with the art. + #[test] + fn scaling_the_symbols_keeps_their_instances_in_place() { + let mut doc = Document::new(100.0, 100.0); + let boxed = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-1.0, -0.5, 1.0, 0.5)), Default::default()); + doc.symbols.push(Symbol { name: "Box".into(), art: Arc::new(boxed), dynamic: false }); + let turn = Affine::translate((3.0, 4.0)) * Affine::rotate(0.5) * Affine::scale(2.0); + let nested = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn }); + let mut pair = Node::group(doc.alloc_id(), vec![Arc::new(nested)]); + // An opacity mask that doesn't move with its object, holding an instance too. + let masking = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn }); + let mut unlinked = vectorcraft_doc::OpacityMask::new(masking, true); + unlinked.linked = false; + pair.extras_mut().mask = Some(Box::new(unlinked)); + doc.symbols.push(Symbol { name: "Pair".into(), art: Arc::new(pair), dynamic: false }); + let place = Affine::translate((40.0, 30.0)) * Affine::scale(3.0); + let mut on_layer = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Pair".into(), xf: place }); + let in_mask = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place }); + on_layer.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(in_mask, true))); + doc.insert(Some(doc.layers[0].id), 0, on_layer).unwrap(); + let tiled = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place }); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)])); + // Each instance's box on the page, by the art of Box it places (through Pair's instance). + let placed = |d: &Document| { + let xf = |n: &Node| match &n.kind { + NodeKind::SymbolInstance { xf, .. } => *xf, + other => panic!("an instance: {other:?}"), + }; + let art = d.symbols[0].art.geometric_bounds().unwrap(); + let layer = &d.layers[0].children().unwrap()[0]; + let mask = &layer.extras().mask.as_ref().unwrap().art; + let tile = &d.patterns[0].art[0]; + let nested = &d.symbols[1].art.children().unwrap()[0]; + let masking = &d.symbols[1].art.extras().mask.as_ref().unwrap().art; + [xf(layer) * xf(nested), xf(layer) * xf(masking), xf(mask), xf(tile)].map(|m| m.transform_rect_bbox(art)) + }; + let before = placed(&doc); + scale_symbols(&mut doc, 10.0); + let after = placed(&doc); + for (a, b) in after.iter().zip(&before) { + let far = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(far < 1e-9, "{after:?} {before:?}"); + } + let pair = &doc.symbols[1].art.children().unwrap()[0]; + let NodeKind::SymbolInstance { xf, .. } = &pair.kind else { panic!("an instance") }; + let want = Affine::translate((30.0, 40.0)) * Affine::rotate(0.5) * Affine::scale(2.0); + let far = xf.as_coeffs().iter().zip(want.as_coeffs()).fold(0.0f64, |m, (a, b)| m.max((a - b).abs())); + assert!(far < 1e-9, "{xf:?}"); + let art = doc.symbols[0].art.geometric_bounds().unwrap(); + assert_eq!(art, Rect::new(-10.0, -5.0, 10.0, 5.0)); + } + + /// Layers read in the units of a page scaled up to points take the art of their opacity masks + /// with them, a mask that doesn't move with its object included, except in data VectorCraft + /// wrote, where that mask's art is at its own size. + #[test] + fn scaling_a_read_document_scales_unlinked_opacity_masks() { + for own_size in [false, true] { + let mut doc = Document::new(100.0, 100.0); + let mut boxes = vec![]; + for linked in [true, false] { + let mask = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(1.0, 2.0, 3.0, 4.0)), Default::default()); + let mut mask = vectorcraft_doc::OpacityMask::new(mask, true); + mask.linked = linked; + let mut n = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Default::default()); + n.extras_mut().mask = Some(Box::new(mask)); + boxes.push((linked, doc.insert(Some(doc.layers[0].id), 0, n).unwrap())); + } + scale_read_document(&mut doc, 10.0, false, own_size); + for (linked, id) in boxes { + let mask = doc.node(id).and_then(|n| n.extras().mask.as_ref().and_then(|m| m.art.geometric_bounds())); + let want = if linked || !own_size { Rect::new(10.0, 20.0, 30.0, 40.0) } else { Rect::new(1.0, 2.0, 3.0, 4.0) }; + assert_eq!(mask, Some(want), "linked {linked}, own size {own_size}"); + } + } + } + /// Area type in `doc`'s first layer at `index`, in the frame `r`. fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId { let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default()); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index d1af3301df..afd8f2568c 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,6 @@ -use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art}; +use super::build::{ + PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art, scale_symbol_art, +}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -45,7 +47,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus let own_palette = use_palette(data, &mut v.document, &mut read); use_brushes(data, &mut v.document, have, &mut v.warnings, &mut read); use_styles(data, &mut v.document, true, 1.0, None, &mut read); - use_symbols(data, &mut v.document, own_palette, &mut v.warnings, &mut read); + use_symbols(data, &mut v.document, own_palette, None, &mut v.warnings, &mut read); use_variables(data, &mut v.document, &mut v.warnings, &mut read); add_notes(&mut v.warnings, read.notes()); } @@ -153,7 +155,7 @@ pub fn layered_ai_from( let own_palette = use_palette(&data, &mut visible, &mut read); use_brushes(&data, &mut visible, have, &mut warnings, &mut read); use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read); - use_symbols(&data, &mut visible, own_palette, &mut warnings, &mut read); + use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut warnings, &mut read); use_variables(&data, &mut visible, &mut warnings, &mut read); add_notes(&mut warnings, read.notes()); return (visible, warnings, false); @@ -172,7 +174,7 @@ pub fn layered_ai_from( use_brushes(&data, &mut visible, have, &mut warnings, &mut read); use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read); let mut all = unread(warnings, why); - use_symbols(&data, &mut visible, own_palette, &mut all, &mut read); + use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut all, &mut read); use_variables(&data, &mut visible, &mut all, &mut read); add_notes(&mut all, read.notes()); (visible, all, false) @@ -377,26 +379,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo if styles.is_empty() { return; } - if !large_canvas && (unit != 1.0 || canvas != 1.0) { - let read; - let boards = match boards { - Some(b) => b, - None => { - read = ai::artboards(data, &mut ai::Names::default()).0; - &read - } - }; - let k = board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)); - if let Some(k) = k.filter(|k| *k != 1.0) { - for g in &mut styles { - g.appearance.scale_strokes(k); - g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); - } + if !large_canvas && let Some(k) = data_scale(data, doc, unit, canvas, boards) { + for g in &mut styles { + g.appearance.scale_strokes(k); + g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); } } doc.graphic_styles = styles.into(); } +/// How many times as large `doc` (a file read as its PDF part) draws the art of its editing data +/// `data`, whose declared large canvas scale is `canvas`, when it isn't 1: `unit` when the editing +/// data's artboards (`boards`, or read from `data`) are the size of `doc`'s pages in units of +/// `unit` points, else `canvas` when they are the pages' size at that scale ([`board_scale`]). +fn data_scale(data: &[u8], doc: &Document, unit: f64, canvas: f64, boards: Option<&[(String, Rect)]>) -> Option { + if unit == 1.0 && canvas == 1.0 { + return None; + } + let read; + let boards = match boards { + Some(b) => b, + None => { + read = ai::artboards(data, &mut ai::Names::default()).0; + &read + } + }; + board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)).filter(|k| *k != 1.0) +} + /// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its /// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and /// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name @@ -405,15 +415,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo /// of the name, which the art then names, as does one whose name a color group has), as [`build`] gives a document read from the layers, and /// the note about instances that can't be drawn added to `warnings`. An object of their art linked /// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The -/// symbols' art keeps the editing data's units, as in a document read from the layers: when -/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art -/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has -/// none this reads. `names` reads the symbols' names and keeps the notes on them. -fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec, names: &mut ai::Names) { +/// symbols' art is drawn as large as a document read from the layers draws it +/// ([`scale_symbol_art`]): with `units` `None` (an EPS's page), as many times as large as the data's +/// declared large canvas scale; with `Some((unit, boards))`, as many times as large as +/// [`use_styles`] draws the styles' strokes and effects ([`data_scale`], `boards` the editing data's +/// artboards); at its own size in data VectorCraft wrote. Left as it is when `doc` has symbols +/// already or the editing data has none this reads. `names` reads the symbols' names and keeps the +/// notes on them. +fn use_symbols( + data: &[u8], + doc: &mut Document, + own_palette: bool, + units: Option<(f64, &[(String, Rect)])>, + warnings: &mut Vec, + names: &mut ai::Names, +) { if !doc.symbols.is_empty() { return; } - let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data, names) else { return }; + let Some(ai::Symbols { symbols, images, swatches, canvas_scale, own_size }) = ai::symbols(data, names) else { return }; + let (mut symbols, registrations): (Vec<_>, Vec<_>) = symbols.into_iter().unzip(); + let k = match units { + _ if own_size => None, + None => Some(canvas_scale), + Some((unit, boards)) => data_scale(data, doc, unit, canvas_scale, Some(boards)), + }; + if let Some(k) = k { + scale_symbol_art(&mut symbols, k); + } for (key, blob) in images { doc.images.entry(key).or_insert(blob); } @@ -444,7 +473,7 @@ fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mu *link = Some(new.clone()); true }; - for (mut symbol, registration) in symbols { + for (mut symbol, registration) in symbols.into_iter().zip(registrations) { let mut art = reid_page_art(doc, &symbol.art); if !renamed.is_empty() { vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow); diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index eaa50b6bbf..005b89397e 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -59,7 +59,7 @@ pub(super) fn make_slots( to_doc: Affine, warn: &mut Vec, ) -> Vec> { - let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false); + let (mut taller, mut deeper, mut tabs_left_out, mut kinsoku_left_out) = (0, 0, false, false); let made = infos .iter() .enumerate() @@ -68,6 +68,7 @@ pub(super) fn make_slots( let story = stories.get(k)?.as_ref()?; let mut node = story.node(NodeId(0), info.frame, t, to_doc)?; tabs_left_out |= story.tabs_left_out(); + kinsoku_left_out |= story.kinsoku_sets_left_out(); let mut region = story.region(t, to_doc); // The first line where the file has it, in the text document's frame (that of a // copied object is its original's, as its lines are). @@ -102,6 +103,9 @@ pub(super) fn make_slots( if tabs_left_out { warn.push(crate::import::ate::TABS_LEFT_OUT.into()); } + if kinsoku_left_out { + warn.push(crate::import::ate::KINSOKU_SETS_LEFT_OUT.into()); + } made } 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..43b088c66f 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -136,6 +136,16 @@ fn codes_map_by_their_codespace_with_notdef_fallbacks() { width("<01>", 30.0); } +/// `kshow` over a CMap runs its procedure between each two codes with their values, whatever +/// their length, and the next character follows where it moves the point (PLRM 3rd ed., `kshow`). +#[test] +fn kshow_runs_between_each_two_cmap_codes() { + let fonts = String::from_utf8(cid_font(true)).unwrap() + CMAP; + let check = |cond: &str| check(&format!("{fonts} /TestFont 50 selectfont {cond}")); + check("gsave nulldevice 10 20 moveto [ {} <418001> kshow ] grestore aload length 2 eq exch 16#8001 eq and exch 65 eq and"); + check("gsave nulldevice 10 20 moveto {pop pop 100 0 rmoveto} <418001> kshow currentpoint grestore pop 165 sub abs 0.01 lt"); +} + /// `usecmap` brings in another CMap's ranges and mappings; the CMap's own come after them. #[test] fn usecmap_takes_the_parents_mappings() { @@ -677,6 +687,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/import/tests_composite.rs b/crates/eps/src/import/tests_composite.rs index e682ce10ec..2f4074af97 100644 --- a/crates/eps/src/import/tests_composite.rs +++ b/crates/eps/src/import/tests_composite.rs @@ -119,6 +119,35 @@ fn widthshow_matches_a_composite_character_by_font_number_and_code() { check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh)); } +/// `kshow` runs its procedure between each two characters of a composite font, with the values of +/// their codes (font number times 256 plus code), and the next character follows where it moves +/// the current point (PLRM 3rd ed., `kshow`). +#[test] +fn kshow_runs_between_each_two_composite_characters() { + let (_, wh) = glyph('H'); + let (_, wi) = glyph('I'); + let fonts = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def"); + let check = |cond: String| super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont {cond}")); + check("gsave nulldevice 10 20 moveto [ {} (\\000\\001\\001\\002\\000\\001) kshow ] grestore aload length 4 eq exch 1 eq and exch 258 eq and exch 258 eq and exch 1 eq and".into()); + check(format!( + "gsave nulldevice 10 20 moveto {{pop pop 100 0 rmoveto}} (\\000\\001\\000\\002) kshow currentpoint grestore pop {} sub abs 0.01 lt", + 10.0 + 50.0 * (wh + wi) + 100.0 + )); + // Drawn, the I starts where the procedure moved the point. + let r = read(&format!("{fonts}/Comp findfont 50 scalefont setfont 10 20 moveto {{pop pop 100 0 rmoveto}} (\\000\\001\\000\\002) kshow")); + let paths: Vec<_> = all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| bounds(n)).collect(); + assert_eq!(paths.len(), 2, "{paths:?}"); + assert!(paths[1].x0 > 10.0 + 50.0 * wh + 100.0 - 1.0, "{paths:?}"); + // A procedure that draws: its square goes after the glyphs' group, outside it. + let r = read(&format!( + "{fonts}/Comp findfont 50 scalefont setfont 10 20 moveto {{pop pop currentpoint 2 2 rectfill}} (\\000\\001\\000\\002\\000\\001) kshow" + )); + let top = super::tests::objects(&r.document); + assert_eq!(top.len(), 3, "{top:?}"); + assert_eq!(top[0].children().map(Vec::len), Some(3)); + assert!(top[1..].iter().all(|n| matches!(n.kind, NodeKind::Path { .. }) && (bounds(n).width() - 2.0).abs() < 0.01), "{top:?}"); +} + /// Measuring a composite font's Type 42 glyphs (`stringwidth` on a long string in a `loop`) /// counts against the operation budget, as drawing them does. #[test] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 1f634b3744..738f02d857 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1809,6 +1809,44 @@ fn an_eps_without_layers_keeps_its_symbols() { assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings); } +/// The box of the art of `d`'s first symbol (Bar, 12 × 2 about the origin in the editing data). +fn bar_bounds(d: &Document) -> Option { + d.symbols.first().and_then(|s| s.art.geometric_bounds()) +} + +/// A symbol library without layers whose editing data is drawn larger than its units comes in with +/// its symbols' art as large as a document read from its layers has it: an EPS of a Large Canvas +/// copy at its declared scale, and a `.ai` whose artboard is its PDF part's page at that scale or +/// in the units of its `UserUnit`. One whose artboard is the page in points keeps its art's size. +#[test] +fn a_symbol_library_in_larger_units_comes_in_at_the_pages_size() { + let board = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let library = |setup: &str| symbol_library().replace("%%BeginSetup\n", &format!("%%BeginSetup\n{setup}")); + let ten = Some(vectorcraft_geom::Rect::new(-60.0, -10.0, 60.0, 10.0)); + let r = import(&eps(&page_square(50, 50), &library("%AI24_LargeCanvasScale: 10\n"))).unwrap(); + assert_eq!(bar_bounds(&r.document), ten, "{:?}", r.warnings); + let large = format!("%AI24_LargeCanvasScale: 10\n{board}"); + let (d, warnings, read) = crate::layered_ai(&private(&library(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + assert_eq!(bar_bounds(&d), ten, "{warnings:?}"); + let (d, warnings, _) = crate::layered_ai(&private(&library(board)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0); + assert_eq!(bar_bounds(&d), ten, "{warnings:?}"); + let points = board.replace("0 100 /Real", "0 1000 /Real").replace("100 0 /Real", "1000 0 /Real"); + let (d, warnings, _) = crate::layered_ai(&private(&library(&points)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0); + assert_eq!(bar_bounds(&d), Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)), "{warnings:?}"); + // A library VectorCraft wrote (its `%%Creator`) has its symbols at their own size, whatever its + // scale. + let own = + |setup: &str| library(setup).replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {}\n", crate::ai_editing::CREATOR_TEXT), 1); + let own_size = Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)); + let r = import(&eps(&page_square(50, 50), &own("%AI24_LargeCanvasScale: 10\n"))).unwrap(); + assert_eq!(bar_bounds(&r.document), own_size, "{:?}", r.warnings); + let (d, warnings, _) = crate::layered_ai(&private(&own(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0); + assert_eq!(bar_bounds(&d), own_size, "{warnings:?}"); +} + /// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their /// layers can't be used (their editing data is for another page) get the symbols defined before /// their layers. @@ -2769,6 +2807,94 @@ fn a_large_canvas_eps_keeps_its_graphic_styles_at_the_pages_size() { assert_eq!((object.appearance.stroke_width(), object.extras().graphic_style), (2.0, Some(1)), "{object:#?}"); } +/// The definition of the symbol Box: a rectangle 2 × 1 about the origin, stroked 0.4 wide. +fn stroked_box_symbol() -> String { + let art = "0 0 0 1 K\n0.4 w\n-1 -0.5 m\n-1 0.5 L\n1 0.5 L\n1 -0.5 L\n-1 -0.5 L\nS\n"; + format!("%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Box", art, 5)) +} + +/// The art of `d`'s symbol Box ([`stroked_box_symbol`]) is ten times as large, its stroke 4 wide, +/// and its instance on `d`'s second layer, placed in the file at twice its size about (`x`, `y`), +/// keeps that scale and draws a box of 40 × 20 about `center`. +fn box_symbol_at_ten_times(d: &Document, center: (f64, f64), notes: &[String]) { + let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{notes:?}")).art; + let b = art.geometric_bounds().unwrap(); + let close = |r: vectorcraft_geom::Rect, e: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(e).all(|(a, b)| (a - b).abs() < 1e-6); + assert!(close(b, [-10.0, -5.0, 10.0, 5.0]), "the art at the page's size: {b:?} {notes:?}"); + let mut widths = vec![]; + art.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) { + widths.push(n.appearance.stroke_width()); + } + }); + assert_eq!(widths, [4.0], "{notes:?}"); + let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{:#?} {notes:?}", d.layers)); + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {:?}", instance.kind) }; + let [a, b0, c, d0, ..] = xf.as_coeffs(); + assert!((a.hypot(b0) - 2.0).abs() < 1e-9 && (c.hypot(d0) - 2.0).abs() < 1e-9, "the instance's own scale: {xf:?}"); + let (x, y) = center; + assert!(close(xf.transform_rect_bbox(b), [x - 20.0, y - 10.0, x + 20.0, y + 10.0]), "{:?}", xf.transform_rect_bbox(b)); +} + +/// The symbols of a Large Canvas editing copy come in at the page's size, as its art does: their +/// art and its strokes ten times as large, and each instance with the scale the file gives it, +/// drawn where and as large as before. +#[test] +fn a_large_canvas_eps_keeps_its_symbols_at_the_pages_size() { + let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 5 5 /RTransformMatrix\n;\n9 () XW"; + let square = "0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf"; + let art = native_large_canvas(10.0, 10.0, &format!("{}{}{}", stroked_box_symbol(), layer("Art", true, square), layer("Box", false, instance))); + let r = import(&eps(&page_square(50, 50), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Box"], "{:?}", r.warnings); + box_symbol_at_ten_times(&r.document, (50.0, 50.0), &r.warnings); +} + +/// A large canvas `.ai` 100 units square at a scale of 10, with the symbol Box +/// ([`stroked_box_symbol`]) and an instance of it at twice its size about (50, 50), its header +/// carrying `creator` when given, read with its PDF part, on a page whose PDF part can't be read, +/// and without a page. +fn large_canvas_ai_box_reads(creator: Option<&str>) -> Vec<(&'static str, Document, Vec)> { + let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 50 50 /RTransformMatrix\n;\n9 () XW"; + let layers = format!("{}{}{}", stroked_box_symbol(), layer("Art", true, &square(10, 10)), layer("Box", false, instance)); + let mut art = native_large_canvas(100.0, 10.0, &layers); + if let Some(creator) = creator { + art = art.replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {creator}\n"), 1); + } + let data = private(&art); + let (d, notes, read) = crate::layered_ai(&data, in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let on_page = crate::ai_alone_on_page(&data, (1000.0, 1000.0), 10.0, Default::default()).unwrap(); + let no_page = crate::ai_alone(&data).unwrap(); + vec![("PDF part", d, notes), ("on a page", on_page.0, on_page.1), ("no page", no_page.0, no_page.1)] +} + +/// A `.ai` whose editing data is read at its declared large canvas scale has its symbols scaled +/// with the art, as an EPS has, however the file opens. +#[test] +fn a_large_canvas_ai_keeps_its_symbols_at_the_pages_size() { + for (how, d, notes) in large_canvas_ai_box_reads(None) { + let notes: Vec = notes.into_iter().chain([how.to_string()]).collect(); + box_symbol_at_ten_times(&d, (500.0, 500.0), ¬es); + } +} + +/// A large canvas `.ai` that VectorCraft wrote (its `%%Creator`) has its symbols defined at their +/// own size, as its `.ai` save writes them: their art keeps that size, and the instance, scaled with +/// the layers, draws the same box, however the file opens. +#[test] +fn a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size() { + for (how, d, notes) in large_canvas_ai_box_reads(Some(crate::ai_editing::CREATOR_TEXT)) { + let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{how}: {notes:?}")).art; + let b = art.geometric_bounds().unwrap(); + assert_eq!(b, vectorcraft_geom::Rect::new(-1.0, -0.5, 1.0, 0.5), "{how}: {notes:?}"); + let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{how}: {:#?} {notes:?}", d.layers)); + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("{how}: an instance: {:?}", instance.kind) }; + let drawn = xf.transform_rect_bbox(b); + let close = [drawn.x0 - 480.0, drawn.y0 - 490.0, drawn.x1 - 520.0, drawn.y1 - 510.0].iter().all(|v| v.abs() < 1e-6); + assert!(close, "{how}: {drawn:?}"); + } +} + #[test] fn type_with_a_listed_art_style_is_linked_to_its_graphic_style() { let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 515e0b12ec..15b9ca34c6 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -999,6 +999,8 @@ struct Mapped { cid: Option<(u32, Option)>, outer: Affine, vertical: bool, + /// The value of the character's code in the shown string (what `kshow` gives its procedure). + value: u32, } /// Type in a CIDFont only named, over a predefined CMap ([`Interp::named_cid_type`]): its codes @@ -1097,7 +1099,8 @@ impl Interp<'_> { if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize)) && font_type(&d.borrow()) != Some(0.0) { - out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false }); + let value = u32::from(pick) * 256 + u32::from(code); + out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false, value }); } } Ok(Some(out)) @@ -1124,21 +1127,28 @@ impl Interp<'_> { .filter_map(|c| match (fonts.get(c.font), c.base) { // A base font's code, or its glyph name (at the code its encoding gives it, if any). (Some(Obj::Dict(d)), Some(Base::Code(code))) => { - Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical, value: c.value }) } (Some(Obj::Dict(d)), Some(Base::Name(name))) => { let code = encoded(&d.borrow(), &name); let name = code.is_none().then_some(name); - Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical, value: c.value }) } // A code without a mapping in a base font (not a CIDFont): its code 0, the // `.notdef` glyph CID 0 stands for in a CIDFont. (Some(Obj::Dict(d)), None) if !d.borrow().contains_key(&Key::name("CIDFontType")) => { - Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical }) - } - (Some(Obj::Dict(d)), None) => { - Some(Mapped { font: d.clone(), pick: 0, code: (c.cid & 0xff) as u8, name: None, cid: Some((c.cid, c.notdef)), outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical, value: c.value }) } + (Some(Obj::Dict(d)), None) => Some(Mapped { + font: d.clone(), + pick: 0, + code: (c.cid & 0xff) as u8, + name: None, + cid: Some((c.cid, c.notdef)), + outer, + vertical, + value: c.value, + }), _ => None, }) .collect()) @@ -1346,6 +1356,12 @@ impl Interp<'_> { fn show_composite(&mut self, glyphs: &[Mapped], spacing: &Spacing) -> Res { let start = self.out.drawn.len(); let mut text = String::new(); + let kern: Vec = match spacing { + Spacing::Kern(_) => glyphs.iter().map(|g| g.value).collect(), + _ => Vec::new(), + }; + // Where in the objects drawn each run of `kshow`'s procedure put its art. + let mut kerned = Vec::new(); for (i, g) in glyphs.iter().enumerate() { let at = self.user_point()?; let w = self.composite_glyph(g, at, Draw::Fill)?; @@ -1353,6 +1369,12 @@ impl Interp<'_> { // times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8). let d = spacing.advance(i, i64::from(g.pick) * 256 + i64::from(g.code), w); self.g.cur = Some(self.xf() * (at + d)); + // `kshow`'s procedure runs between each two characters with the values of their codes. + let before = self.out.drawn.len(); + self.kern(spacing, &kern, i, at + d)?; + if self.out.drawn.len() > before { + kerned.push(before..self.out.drawn.len()); + } // A CID stands for no character (its code is only the CID's low byte). let c = if g.cid.is_some() { '\u{2022}' } else { g.name.as_deref().map_or_else(|| code_char(&g.font.borrow(), g.code), glyph_char) }; if c != '\u{2022}' { @@ -1364,7 +1386,13 @@ impl Interp<'_> { { text = name.to_string(); } + // What `kshow`'s procedure draws goes after the type, outside its group. + let mut art = Vec::new(); + for run in kerned.into_iter().rev() { + art.splice(0..0, self.out.drawn.drain(run)); + } self.group_glyphs(start, &text); + self.out.drawn.extend(art); Ok(()) } diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 4436c3a09f..663e21bec6 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -658,6 +658,47 @@ fn images_are_written_in_the_form_the_specification_shows() { assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}"); } +/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp on an +/// image) are written as the image whole once, then each as the part of it that its triangle shows, +/// and the file draws as the document does ([`write`] runs it). +#[test] +fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each of a warped image's 200 pieces was written with all of the image's samples. + let mut doc = Document::new(200.0, 160.0); + let mut png = vec![]; + image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { + key, + width: 64, + height: 64, + xf: Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), + link: None, + placement: Default::default(), + pixels: None, + }; + let photo = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im.clone()))); + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + let warped = vectorcraft_doc::live::mesh_warp_image(&photo, &im, &bend, 10, false, false); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + let text = String::from_utf8(write(&doc).bytes).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 200); + let digits = |lines: &Vec<&str>| { + lines.iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).map(str::len).sum::() + }; + let whole = 64 * 64 * 3 * 2; + assert_eq!(digits(&images[0]), whole, "the first piece holds the whole image"); + // Each piece holds its cell (two triangles share one) and 2 pixels more around it. + let total: usize = images.iter().map(digits).sum(); + assert!(total < 10 * whole, "{total} digits of samples for an image of {whole}"); +} + /// An image whose data is empty is left out with the note for empty or too large images, and an /// image whose data can't be decoded is left out with the decode note. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index eda25a6322..0b41c3defa 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -45,6 +45,13 @@ pub(super) struct Writer<'a> { roman: bool, /// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type). kumi: bool, + /// The outlines of the clipping groups the art being written is in, in the document. + clips: Vec, + /// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow + /// one another, decode it once. + last_image: Option<(ImageBlob, Option>)>, + /// The images written whole so far, by the address of their data (which each entry keeps). + whole_images: std::collections::HashMap>>, } impl<'a> Writer<'a> { @@ -68,6 +75,9 @@ impl<'a> Writer<'a> { fonts: vec![], roman: false, kumi: false, + clips: vec![], + last_image: None, + whole_images: std::collections::HashMap::new(), } } @@ -363,12 +373,14 @@ impl<'a> Writer<'a> { if several { self.line("*U"); } + self.clips.push(outline); if let Some(fill) = &paint.fill { self.node(fill, locked); } for c in rest { self.node(c, locked); } + self.clips.pop(); self.lock(locked); self.line("Q"); if let Some(stroke) = &paint.stroke { @@ -1037,7 +1049,8 @@ impl<'a> Writer<'a> { /// whose pixels are all black or white is written as an image mask (ImageMask 1, §8.1): its /// color as the fill, then 1-bit samples, a 0 bit for each black pixel, which takes the color, /// and a 1 bit for each white pixel, which is clear. Another colorized image is written with - /// its color in its pixels. + /// its color in its pixels. In a clipping group, an image whose pixels were written whole + /// already is written as the part of them the group shows ([`ai_ops::image_window`]). fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, colorize: Option<&Paint>) { let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let m = self.to_art * xf * flip; @@ -1048,7 +1061,7 @@ impl<'a> Writer<'a> { self.warn(LARGE_IMAGE); return; } - let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else { + let Some((blob, img)) = blob.and_then(|b| Some((b, self.decoded(b)?))) else { self.warn(UNDECODED); return; }; @@ -1059,14 +1072,31 @@ impl<'a> Writer<'a> { } // The object places the image's own pixel grid (its width × height) wherever the file's is. let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h)); - let m = self.to_art * xf * fit * flip; + let coverage = match colorize { + Some(paint) => { + let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { + self.warn(UNDECODED); + return; + }; + Some((paint, coverage)) + } + None => None, + }; + // An image written whole already, cut to a clipping group: the part the group shows, placed + // where it is in the image (the pieces of a warped image each show a small part of the same one). + let key = Arc::as_ptr(&blob.bytes) as usize; + let window = if self.whole_images.contains_key(&key) { ai_ops::image_window(&self.clips, xf * fit, (w, h)) } else { None }; + if window.is_none() { + self.whole_images.insert(key, blob.bytes.clone()); + } + let window = window.unwrap_or([0, 0, w, h]); + let [x0, y0, x1, y1] = window; + let m = self.to_art * xf * fit * Affine::translate((f64::from(x0), f64::from(y0))) * flip; + let (w, h) = (x1 - x0, y1 - y0); // Over white. let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8; - if let Some(paint) = colorize { - let Some((_, _, coverage)) = blob.and_then(ImageBlob::coverage).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { - self.warn(UNDECODED); - return; - }; + if let Some((paint, coverage)) = coverage { + let coverage = ai_ops::window_samples(&coverage, img.width(), 1, window); if coverage.iter().all(|&v| v == 0 || v == 255) { let stride = (w as usize).div_ceil(8); let mut bits = vec![0xFF; stride.saturating_mul(h as usize)]; @@ -1086,7 +1116,7 @@ impl<'a> Writer<'a> { self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples); return; } - let rgba = img.to_rgba8(); + let rgba = img.crop_imm(x0, y0, w, h).to_rgba8(); let mut samples = Vec::with_capacity(rgba.len() / 4 * 3); let mut see_through = false; for px in rgba.pixels() { @@ -1102,6 +1132,19 @@ impl<'a> Writer<'a> { self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples); } + /// The pixels of `blob`, decoded once for images that follow one another with the same data. + fn decoded(&mut self, blob: &ImageBlob) -> Option> { + if let Some((last, img)) = &self.last_image + && Arc::ptr_eq(&last.bytes, &blob.bytes) + && last.mime == blob.mime + { + return img.clone(); + } + let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new); + self.last_image = Some((blob.clone(), img.clone())); + img + } + /// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]` /// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`] /// digits, `%AI5_EndRaster`, then `F`. diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 49b64f4bce..534778286a 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -230,7 +230,8 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb } // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); - let baked = vectorcraft_effects::bake_document(doc); + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, true, vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); let mut setting_notes = vec![]; diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 023dbdcbe1..299d130f7d 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -931,18 +931,21 @@ impl<'a> Scene<'a> { /// process inks the stops use, in CMYK order, and the spot inks, in the order the stops name /// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates /// print with its ink. An off-center midpoint is a stop - /// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and, - /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method - /// or around the hue wheel; it is then written in process colors. + /// with the inks halfway between its two stops. Stop opacity is left out, so the stops that + /// color a stretch ([`vectorcraft_color::Gradient::reached_stops`]) name the inks, a spot ink + /// only above a tint of 0 or with process plates; the others are written with no ink. `None` for a gradient without a + /// spot ink, and, with a warning, for one whose inks can't be kept or that is mixed by the + /// Perceptual method or around the hue wheel; it is then written in process colors. fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> { let doc = self.doc; let spot_of = |s: &vectorcraft_color::GradientStop| -> Option { s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)).map(str::to_string) }; + let reached = g.reached_stops(); // The spot inks, collected up to one more than a DeviceN space can hold. let mut names: Vec = vec![]; - for s in &g.stops { - if let Some(n) = spot_of(s) + for (s, &r) in g.stops.iter().zip(&reached) { + if let Some(n) = spot_of(s).filter(|_| r && (s.tint > 0.0 || s.plates.is_some())) && !names.contains(&n) { names.push(n); @@ -971,9 +974,10 @@ impl<'a> Scene<'a> { }; // Each stop's ink coverage: the four process inks, then one tint per spot ink. let mut inks: Vec> = Vec::with_capacity(g.stops.len()); - for s in &g.stops { + for (s, &r) in g.stops.iter().zip(&reached) { let mut v = vec![0.0; 4 + names.len()]; match spot_of(s) { + _ if !r => {} Some(n) => { if let Some(i) = names.iter().position(|m| *m == n) { v[4 + i] = unit(s.tint); @@ -1116,7 +1120,10 @@ impl<'a> Scene<'a> { // keep no swatch links: the warning [`Self::ink_stops`] gives the others. if g.gradient.method.is_hue() && g.gradient.is_spread() && g.gradient.spread == vectorcraft_color::GradientSpread::Reflect { let doc = self.doc; - if g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { + let spot = |s: &vectorcraft_color::GradientStop| { + (s.tint > 0.0 || s.plates.is_some()) && s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot)) + }; + if g.gradient.stops.iter().zip(g.gradient.reached_stops()).any(|(s, r)| r && spot(s)) { self.warn(SPOT_GRADIENTS_HUE); } } diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index e9d6f79521..92971cee01 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -532,6 +532,66 @@ fn a_gradient_mixing_a_spot_ink_with_rgb_is_written_without_the_ink() { assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); } +#[test] +fn gradients_name_only_the_spot_inks_of_stops_that_color_a_stretch() { + let black = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let paper = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 0.0)); + let no_spot_space = |ps: &str| !ps.contains("(Gold Ink) cvn") && !ps.contains("/DeviceN") && !ps.contains("/Separation"); + for level in [Level::Two, Level::Three] { + // Gold Ink out of order: the stops at 0 and 0.8 cover the stretches next to it, so it + // colors nothing and has no plate. + let d = spot_gradient_doc(vec![black(0.0), black(0.8), spot_stop(0.2, "Gold Ink", GOLD, 1.0), black(0.4), black(1.0)]); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + assert_eq!(dsc(&ps, "DocumentCustomColors"), None, "{level:?}: {ps}"); + assert!(no_spot_space(&ps), "{level:?}: {ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{level:?}: {:?}", out.warnings); + checked_page(&ps); + // Without it, Leaf Ink's tints over paper are a Separation shading. + let d = spot_gradient_doc(vec![ + spot_stop(0.0, "Leaf Ink", LEAF, 1.0), + spot_stop(0.8, "Leaf Ink", LEAF, 0.5), + spot_stop(0.2, "Gold Ink", GOLD, 1.0), + paper(0.4), + paper(1.0), + ]); + let ps = text(&export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Leaf Ink)"), "{level:?}: {ps}"); + assert!(ps.contains("/VCsp0 [/Separation (Leaf Ink) cvn /DeviceCMYK") && !ps.contains("(Gold Ink) cvn"), "{level:?}: {ps}"); + checked_page(&ps); + // An RGB stop out of order colors nothing either, so it keeps no ink out. + let d = spot_gradient_doc(vec![ + spot_stop(0.0, "Gold Ink", GOLD, 1.0), + spot_stop(0.8, "Gold Ink", GOLD, 0.5), + GradientStop::new(0.2, Color::rgb(0.0, 1.0, 0.0)), + paper(0.4), + paper(1.0), + ]); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + assert!(ps.contains("/VCsp0 [/Separation (Gold Ink) cvn /DeviceCMYK"), "{level:?}: {ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{level:?}: {:?}", out.warnings); + // Gold Ink at a tint of 0 prints no ink. + let d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 0.0), black(1.0)]); + let ps = text(&export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors"), None, "{level:?}: {ps}"); + assert!(no_spot_space(&ps), "{level:?}: {ps}"); + } + // Gold Ink at a tint of 0 over a 30% cyan plate (a DeviceN gradient read from a PDF) keeps + // its process plate in the same space. + let plated = GradientStop { plates: Some([0.3, 0.0, 0.0, 0.0]), ..spot_stop(1.0, "Gold Ink", GOLD, 0.0) }; + let d = spot_gradient_doc(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), plated]); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert!(ps.contains("/VCdn0 [/DeviceN [/Cyan (Gold Ink) cvn ] /DeviceCMYK") && checked_page(&ps).contains("/C0 [0.3 0] /C1 [0.3 0]"), "{ps}"); + // EPS leaves stop opacity out, so a clear Gold Ink stop between clear stops is written opaque + // and keeps its plate. + let clear = |s: GradientStop| GradientStop { opacity: 0.0, ..s }; + let d = spot_gradient_doc(vec![black(0.0), clear(black(0.2)), clear(spot_stop(0.4, "Gold Ink", GOLD, 1.0)), clear(black(0.6)), black(1.0)]); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Gold Ink)"), "{ps}"); + assert!(ps.contains("/VCdn0 [/DeviceN [/Black (Gold Ink) cvn ] /DeviceCMYK"), "{ps}"); +} + #[test] fn spot_inks_named_all_or_none_are_written_under_names_of_their_own() { let rect = |d: &mut Document, fill: &str, stroke: &str| { @@ -1264,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() }); @@ -1521,6 +1654,23 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() { assert!(page.contains("1 0 0 rg"), "{page}"); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // The symbol's square at (10, 40), which a Transform effect moves 100 pt right, is drawn from + // x = 110 and not from x = 10. + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })]); + let mut art = path(&mut d, shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + let page = page_of(&d, &opts(&d)); + let starts: Vec = page.lines().filter(|l| l.ends_with(" m")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect(); + assert!(!starts.is_empty() && starts.iter().all(|x| (110.0..=120.0).contains(x)), "{starts:?}\n{page}"); +} + #[test] fn level_preview_and_overprint_ids_read_back() { for l in Level::ALL { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6529cf175d..beda6d8ade 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -490,6 +490,43 @@ fn trace_and_style_prompt_steps_run_in_order() { assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}"); } +/// A window capture and an artboard preview scaled down keep the color of opaque pixels beside +/// clear ones: the clear black beside them does not darken them. +#[test] +fn scaled_down_pictures_keep_the_color_beside_clear_pixels() { + struct Striped; + impl Backend for Striped { + fn call(&mut self, method: &str, _: Value) -> Result { + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = vectorcraft_format::base64_encode(png.get_ref()); + Ok(match method { + "document.inspect" => json!({"artboards": [{"width": 64, "height": 4}]}), + _ => json!({"pngBase64": png, "width": 64, "height": 4}), + }) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "test".into() + } + } + + let mut s = Server::new(Box::new(Striped)); + let shots = [("screenshot", json!({"window": true, "max_side": 16})), ("render_preview", json!({"max_side": 16}))]; + for (id, (tool, args)) in (1..).zip(shots) { + let r = call(&mut s, id, tool, args); + let data = + r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("{tool}: {r}"))["data"].as_str().unwrap(); + let img = image::load_from_memory(&vectorcraft_format::base64_decode(data).unwrap()).unwrap().into_rgba8(); + assert_eq!(img.dimensions(), (16, 1), "{tool}"); + assert!(img.pixels().all(|p| p.0 == [200, 30, 30, 128]), "{tool}: {:?}", img.get_pixel(0, 0)); + } +} + /// `screenshot` and `render_preview` with `traceViews: true` draw an Image Trace object in the /// view set with imageTrace.setView, as the app's window does; without it they draw the tracing /// result, as exports do. The image is a dark disc (r = 30 px) on blue, traced with Ignore White. diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index b1c5af70bb..8e514ac48d 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -4,6 +4,7 @@ use super::handlers::{ select_ids, transform, }; use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, json, writable_format}; +use vectorcraft_doc::resample::{Resample, fitted}; /// The `menu` argument of the command listings. pub(super) const MENU_FILTER: &str = @@ -445,7 +446,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result { let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?; // The render was asked for at most 4096 a side. let img = super::handlers::decode_png(&png, 8192).map_err(|e| format!("read preview: {e}"))?; - let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img }; + let img = if img.width() > max || img.height() > max { fitted(img, max, max, Resample::Average) } else { img }; let mut out = std::io::Cursor::new(Vec::new()); img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?; Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()}))) diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index 39d3f3f8e1..5aefa20d31 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -1,5 +1,6 @@ use super::dispatch::trace_views; use super::{Backend, Map, ToolResult, Value, json}; +use vectorcraft_doc::resample::{Resample, fitted}; // ---------- implementation ---------- @@ -521,7 +522,7 @@ fn fit_capture(png: Vec, max: Option, info: &mut Value) -> Result 1.0 { let nw = ((f64::from(w) / k).floor() as u32).max(1); let nh = ((f64::from(h) / k).floor() as u32).max(1); - image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle) + let how = Resample::Filter(image::imageops::FilterType::Triangle); + resized(image::DynamicImage::ImageRgba8(img), nw, nh, how).into_rgba8() } else { img }; @@ -447,4 +450,18 @@ mod tests { let f = Rgba::fitted(img); assert_eq!((f.width, f.height), (MAX_SIDE, 2)); } + + /// A large image scaled to fit keeps the color of its opaque pixels beside clear ones: the + /// clear black beside them does not darken them. + #[test] + fn large_images_keep_their_color_beside_clear_pixels() { + let img = + image::RgbaImage::from_fn(MAX_SIDE * 2, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let f = Rgba::fitted(img); + assert_eq!((f.width, f.height), (MAX_SIDE, 2)); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for p in f.pixels.as_chunks::<4>().0 { + assert!(p[3] > 0 && [200, 30, 30].iter().zip(p).all(|(&want, &c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}"); + } + } } diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index eabba52d9e..6cf8677269 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -45,8 +45,15 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let set = &opts.settings; set.check()?; vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; - // Live geometry effects export as their result; raster effects are reported below. - let baked = vectorcraft_effects::bake_document(doc); + // Live geometry effects export as their result, in symbols' art too; raster effects are + // reported below. + // Only the layers this export writes place their instances' art (see `ex.non_printing` and + // `ex.layers` below). + let layers = vectorcraft_effects::WrittenLayers { + hidden: set.writes_layers() && !doc.page_knockout, + non_printing: set.include_non_printing || set.create_layers, + }; + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true, layers); // Baking stops early when cancelled, leaving a half-baked copy that must not be written. vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); 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/paint.rs b/crates/pdf/src/export/paint.rs index 77815f93b3..6baffa48a8 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -90,16 +90,20 @@ impl Exporter<'_> { /// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of /// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the /// tint and opacity there are halfway). A gradient that mixes a spot ink with other colors - /// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink. + /// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink that prints + /// ([`Self::printed_spot`]). fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { - let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; + let drawn = g.drawn_stops(); + let ink = g.stops.iter().zip(&drawn).find_map(|(s, &d)| self.printed_spot(s, d))?; let tints: Option> = g .stops .iter() - .map(|s| match s.swatch.as_deref() { + .zip(&drawn) + .map(|(s, &d)| match s.swatch.as_deref() { + _ if !d => Some(0.0), // A stop that also prints process plates needs a DeviceN space. Some(n) if n == ink && s.plates.is_none() => Some(s.tint), - _ if self.paper_stop(s) => Some(0.0), + _ if self.inkless_stop(s) => Some(0.0), _ => None, }) .collect(); @@ -126,19 +130,22 @@ impl Exporter<'_> { /// the order the stops name them), with midpoints as explicit stops (the tints and opacity /// there are halfway), or for a Perceptual gradient the stops on its curve /// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink; a spot stop with - /// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. `None`, with a - /// warning, when a stop is written in RGB or Lab or the spot inks can't share a DeviceN space; - /// the gradient is then written in the colors of its stops. + /// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. Only the stops + /// that color something that draws ([`vectorcraft_color::Gradient::drawn_stops`]) name inks: + /// the others are written with no ink. `None`, with a warning, when a stop is written in RGB or + /// Lab or the spot inks can't share a DeviceN space; the gradient is then written in the colors + /// of its stops. fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process}; let doc = self.doc; fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> { s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)) } + let drawn = g.drawn_stops(); // Each spot ink: its swatch's name, then its colorant name and CMYK equivalent. let mut spots: Vec<(&str, (String, [f32; 4]))> = Vec::new(); - for s in &g.stops { - if let Some(n) = spot_of(doc, s) + for (s, &d) in g.stops.iter().zip(&drawn) { + if let Some(n) = self.printed_spot(s, d) && !spots.iter().any(|(m, _)| *m == n) { spots.push((n, self.spot_ink(n)?)); @@ -146,12 +153,15 @@ impl Exporter<'_> { } // Each stop's ink coverage: the four process inks, then one tint per spot ink. let mut inks: Vec> = Vec::with_capacity(g.stops.len()); - for s in &g.stops { + for (s, &d) in g.stops.iter().zip(&drawn) { let mut v = vec![0.0; 4 + spots.len()]; match spot_of(doc, s) { + _ if !d => {} Some(n) => { - let i = spots.iter().position(|(m, _)| *m == n)?; - v[4 + i] = s.tint; + // A spot ink that no drawn stop prints ([`Self::printed_spot`]) has no colorant. + if let Some(i) = spots.iter().position(|(m, _)| *m == n) { + v[4 + i] = s.tint; + } if let Some(p) = s.plates { v[..4].copy_from_slice(&p.map(|t| t.clamp(0.0, 1.0))); } @@ -434,18 +444,38 @@ impl Exporter<'_> { !s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && paper(&s.color) } - /// The spot swatch whose tints are every stop of `g` that isn't paper white - /// ([`Self::paper_stop`]); `None` when no stop is linked to a spot swatch or a stop has - /// another color. - fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> { - let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)); - let ink = g.stops.iter().find_map(spot)?; - g.stops.iter().all(|s| (spot(s) == Some(ink) && s.plates.is_none()) || self.paper_stop(s)).then_some(ink) + /// The spot swatch stop `s` prints: the one it is linked to, when the stop colors something + /// that draws (`drawn`, [`vectorcraft_color::Gradient::drawn_stops`]) at a tint above 0 or + /// with process plates ([`vectorcraft_color::GradientStop::plates`]), which print in the same + /// DeviceN space. + fn printed_spot<'s>(&self, s: &'s vectorcraft_color::GradientStop, drawn: bool) -> Option<&'s str> { + s.swatch.as_deref().filter(|n| drawn && (s.tint > 0.0 || s.plates.is_some()) && self.doc.swatch(n).is_some_and(|w| w.spot)) } - /// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop - /// linked to a spot swatch, and is every other stop paper white ([`Self::paper_stop`]) or - /// written in CMYK or gray? + /// Does stop `s` add no ink to a Separation or DeviceN shading: is it paper white + /// ([`Self::paper_stop`]) or linked to a spot swatch at a tint of 0 without process plates? + fn inkless_stop(&self, s: &vectorcraft_color::GradientStop) -> bool { + let spot = s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)); + self.paper_stop(s) || (spot && self.printed_spot(s, true).is_none()) + } + + /// The spot swatch whose tints are every stop of `g` that colors something that draws + /// ([`vectorcraft_color::Gradient::drawn_stops`]) and adds ink ([`Self::inkless_stop`]); `None` + /// when no such stop prints a spot swatch ([`Self::printed_spot`]) or one has another color. + fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> { + let drawn = g.drawn_stops(); + let ink = g.stops.iter().zip(&drawn).find_map(|(s, &d)| self.printed_spot(s, d))?; + g.stops + .iter() + .zip(&drawn) + .all(|(s, &d)| !d || (s.swatch.as_deref() == Some(ink) && s.plates.is_none()) || self.inkless_stop(s)) + .then_some(ink) + } + + /// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): does a stop + /// print a spot swatch ([`Self::printed_spot`]), and is every other stop that colors something + /// that draws linked to a spot swatch, paper white ([`Self::paper_stop`]) or written in CMYK or + /// gray? fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool { let doc = self.doc; let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; @@ -453,7 +483,9 @@ impl Exporter<'_> { let inks = |s: &vectorcraft_color::GradientStop| { self.paper_stop(s) || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. }) }; - g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s)) + let drawn = g.drawn_stops(); + let mut stops = g.stops.iter().zip(&drawn); + stops.clone().any(|(s, &d)| self.printed_spot(s, d).is_some()) && stops.all(|(s, &d)| !d || spot(s) || inks(s)) } /// Is `g` a Perceptual linear or radial gradient of spot inks, alone or mixed with process @@ -485,16 +517,20 @@ impl Exporter<'_> { let doc = self.doc; let full = doc.linked_color(doc.swatch(ink)?.paint.color()?, true); let space = self.separation(ink)?; - // The stops keep their own tints; paper white is 0% of the ink. - let own: Vec = g.stops.iter().map(|s| if s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect(); + // The stops keep their own tints; paper white, and a stop that colors nothing that draws, + // is 0% of the ink. + let own: Vec = + g.stops.iter().zip(g.drawn_stops()).map(|(s, d)| if d && s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect(); Some(g.ink_tints(&full, &own)?.into_iter().map(|(o, t, a)| (o, SepColor::new_f32(t, space.clone()).into(), a)).collect()) } - /// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is - /// in process colors, so the spot inks have no plate of their own. + /// Warn when gradient `g`, drawn as an image, has stops that print a spot swatch + /// ([`Self::printed_spot`]): the image is in process colors, so the spot inks have no plate of + /// their own. fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) { let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); - if from_stops && self.has_spot_stop(&g.gradient) { + let prints_spot = || g.gradient.stops.iter().zip(g.gradient.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some()); + if from_stops && prints_spot() { self.warn( "spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); 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..d8b873364c 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; @@ -21,12 +23,14 @@ use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageRea use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; use vectorcraft_doc::ImageBlob; -use vectorcraft_doc::cmyk::jpeg_components; +use vectorcraft_doc::cmyk::{Inks, jpeg_components}; +use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply}; 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 +38,7 @@ struct Raw { width: u32, height: u32, space: ImageColorspace, + icc: Option>>, } impl CustomImage for Raw { @@ -54,7 +59,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 +84,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 +93,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 +167,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. @@ -225,15 +278,29 @@ fn jpeg_quality(r: &Rule, jpeg: bool) -> Option { } } -/// `img` resampled to `w` × `h` pixels as `r` says. +/// How `r` resamples an image. +fn resampling(r: &Rule) -> Resample { + match r.downsample { + Downsample::Average => Resample::Average, + Downsample::Subsample => Resample::Filter(FilterType::Nearest), + Downsample::Bicubic | Downsample::None => Resample::Filter(FilterType::CatmullRom), + } +} + +/// `img` resampled to `w` × `h` pixels as `r` says, the colors of an image with alpha weighted by +/// it ([`resized`]). fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { + resized(img, w, h, resampling(r)) +} + +/// The four channels of `img` resampled to `w` × `h` pixels as `r` says, each on its own. +fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { if (w, h) == (img.width(), img.height()) { return img; } - match r.downsample { - Downsample::Average => img.thumbnail_exact(w, h), - Downsample::Subsample => img.resize_exact(w, h, FilterType::Nearest), - Downsample::Bicubic | Downsample::None => img.resize_exact(w, h, FilterType::CatmullRom), + match resampling(r) { + Resample::Average => img.thumbnail_exact(w, h), + Resample::Filter(f) => img.resize_exact(w, h, f), } } @@ -291,7 +358,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 +390,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,40 +399,70 @@ 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 { return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) }; }; - let dims = (inks.width, inks.height); - let (width, height) = sized(&rule, dims, size); - let alpha = match alpha.map(|a| image::GrayImage::from_raw(inks.width, inks.height, a)) { - Some(Some(a)) => Some(resample(DynamicImage::ImageLuma8(a), &rule, (width, height)).into_luma8().into_raw()), - Some(None) => return Recoded { image: None, warning: None }, - None => None, - }; - // The four inks resample as the four channels of an RGBA image do. - let inks = match image::RgbaImage::from_raw(inks.width, inks.height, inks.data) { - Some(img) => resample(DynamicImage::ImageRgba8(img), &rule, (width, height)).into_rgba8().into_raw(), - None => return Recoded { image: None, warning: Some(CMYK_RGB) }, + let (width, height) = sized(&rule, (inks.width, inks.height), size); + let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) { + Ok(r) => r, + Err(warning) => return Recoded { image: None, warning }, }; let (samples, space) = match convert { Some(p) => p.convert(&inks), 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 } } +/// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any. +type InksAndAlpha = (Vec, Option>); + +/// Ink amounts `inks` and their `alpha` (one sample a pixel) resampled to `to` as `r` says. The +/// inks under clear pixels don't count where neighbors blend: they are multiplied by the alpha +/// before resampling and divided by the resampled alpha after. `Err` with the warning when they +/// can't be read. +fn resampled_inks(inks: Inks, alpha: Option>, r: &Rule, to: (u32, u32)) -> Result> { + let dims = (inks.width, inks.height); + let weighted = to != dims && resampling(r) != Resample::Filter(FilterType::Nearest); + let mut data = inks.data; + let alpha = match alpha.map(|a| image::GrayImage::from_raw(dims.0, dims.1, a)) { + Some(Some(a)) => { + if weighted { + premultiply(&mut data, a.as_raw()); + } + Some(resample(DynamicImage::ImageLuma8(a), r, to).into_luma8().into_raw()) + } + Some(None) => return Err(None), + None => None, + }; + // The four inks resample as the four channels of an RGBA image do, each on its own. + let img = image::RgbaImage::from_raw(dims.0, dims.1, data).ok_or(Some(CMYK_RGB))?; + let mut inks = resample_channels(DynamicImage::ImageRgba8(img), r, to).into_rgba8().into_raw(); + if let Some(a) = alpha.as_deref().filter(|_| weighted) { + unpremultiply(&mut inks, a); + } + Ok((inks, alpha)) +} + /// The most one image read here may allocate (bytes), as the image reader allows by default. const MAX_DECODE: u64 = 512 << 20; @@ -405,9 +503,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 +518,28 @@ 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() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The inks of a CMYK image with alpha, resampled, keep the amounts of the opaque pixels: the + /// inks under the clear pixels beside them don't thin them out. + #[test] + fn resampled_inks_keep_the_amounts_beside_clear_pixels() { + let data = [[40, 80, 120, 160], [0, 0, 0, 0]].repeat(8).concat(); + let alpha = [255, 0].repeat(8); + for method in ["average", "bicubic"] { + let c: CompressionSettings = serde_json::from_value(serde_json::json!({"color": {"downsample": method}})).unwrap(); + let inks = Inks::new(16, 1, data.clone()).unwrap(); + let (inks, alpha) = resampled_inks(inks, Some(alpha.clone()), &rule(&c, Kind::Color), (4, 1)).unwrap(); + let alpha = alpha.unwrap(); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for (p, &a) in inks.as_chunks::<4>().0.iter().zip(&alpha) { + assert!(a > 0 && p.iter().zip([40, 80, 120, 160]).all(|(&c, want)| m(c, a).abs_diff(m(want, a)) <= 1), "{method}: {p:?} at {a}"); + } + } + } } 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.rs b/crates/pdf/src/print.rs index 12c71756c8..2a3cbf98af 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -183,11 +183,14 @@ pub fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { /// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`] /// says. pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> { - let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned); - let d = doc.to_mut(); - keep_layers(&mut d.layers, set.print_layers); - composite_overprints(d, set); - doc + // The layers that do not print are left out first, so the art placed for symbols is only + // the art that prints; the layers kept are shown. + let mut kept = doc.clone(); + keep_layers(&mut kept.layers, set.print_layers); + let layers = vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true }; + let mut d = vectorcraft_effects::bake_document_placing_symbols(&kept, true, layers).unwrap_or(kept); + composite_overprints(&mut d, set); + Cow::Owned(d) } /// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated 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.rs b/crates/pdf/src/tests.rs index bee80238bd..5e9796b9b4 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -246,6 +246,26 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() { assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds()); } +/// The live effects in a symbol's art apply where the instance places the art, as on the canvas: +/// the symbol's square at (10, 40), which a Transform effect moves 100 pt right, is written at +/// (110, 40). +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + let mut d = doc(200.0, 100.0); + let mut square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + square.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(square) }); + add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); +} + /// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas /// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20. #[test] @@ -336,6 +356,31 @@ fn a_stained_instance_keeps_its_stain() { } } +/// A stained instance of a symbol whose art has a live effect is written in its stained colors, +/// where the effect draws the art. +#[test] +fn a_stained_instance_keeps_its_stain_when_its_art_has_effects() { + let blue = Color::rgb8(30, 90, 220); + let square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), blue); + let half = stained_instance("Moved", Affine::IDENTITY, 0.5, true); + let want = vectorcraft_brush::instance_art(&square, &half).appearance.fill_paint().color().unwrap().to_hex(); + assert_ne!(want, blue.to_hex(), "the stain changes the color"); + let mut art = square; + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + let mut d = doc(200.0, 100.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + add(&mut d, half); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); + assert_eq!(leaf_fills(&out), [want]); +} + /// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES` /// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through /// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy 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/compression.rs b/crates/pdf/src/tests/compression.rs index c02f3f5e1c..076c3c08c9 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -73,6 +73,38 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() { assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi"); } +/// An image with clear pixels, resampled, keeps the color of its opaque pixels: the clear black +/// beside them does not darken them. +#[test] +fn resampled_images_keep_their_color_beside_clear_pixels() { + let striped = image::RgbaImage::from_fn(600, 600, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let d = doc(encoded(striped.into(), ImageFormat::Png), "image/png"); + for method in ["average", "bicubic"] { + let mut settings: PdfSettings = + serde_json::from_value(json!({"compression": {"color": {"downsample": method, "ppi": 150, "abovePpi": 225, "compression": "zip"}}})) + .unwrap(); + settings.compression.compress_text = false; + let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let rgb = file + .objects() + .into_iter() + .find_map(|o| match o { + Object::Stream(s) + if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") && s.dict().contains_key(b"SMask") => + { + Some(s.decoded().unwrap().into_owned()) + } + _ => None, + }) + .unwrap_or_else(|| panic!("{method}: a color image with a soft mask")); + assert_eq!(rgb.len(), 150 * 150 * 3, "{method}"); + for p in rgb.as_chunks::<3>().0 { + assert!(p[0].abs_diff(200) <= 3 && p[1].abs_diff(30) <= 3 && p[2].abs_diff(30) <= 3, "{method}: {p:?}"); + } + } +} + /// The width and height of each image XObject in `pdf`. fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> { let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); diff --git a/crates/pdf/src/tests/links.rs b/crates/pdf/src/tests/links.rs index c23854439f..f4792e7fa0 100644 --- a/crates/pdf/src/tests/links.rs +++ b/crates/pdf/src/tests/links.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{Array, Dict, Name}; use vectorcraft_color::Color; -use vectorcraft_doc::{Document, Node, NodeId}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, Rect, shapes}; use super::{add, doc, rect_node}; use crate::*; @@ -89,6 +89,27 @@ fn hidden_and_unprinted_objects_have_no_links_and_clipping_trims_them() { assert_eq!(links(&export(&d, &PdfOptions::default()).unwrap()), vec![Vec::<(String, [f32; 4])>::new()]); } +/// A linked symbol instance whose art has a live effect links over the art where the effect draws it. +#[test] +fn a_linked_instance_keeps_its_link_when_its_art_has_effects() { + let mut art = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(0.0, 0.0, 1.0)); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + let mut d = doc(200.0, 100.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + let mut instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY }); + instance.edit_attrs(|a| a.url = "https://example.com/symbol".into()); + add(&mut d, instance); + let pages = links(&export(&d, &PdfOptions::default()).unwrap()); + let [(uri, rect)] = pages[0].as_slice() else { panic!("one link: {pages:?}") }; + assert_eq!(uri, "https://example.com/symbol"); + // x 110…120 after the 100 pt move; y up: 100 − 50 to 100 − 40. + assert!(near(*rect, [110.0, 50.0, 120.0, 60.0]), "{rect:?}"); +} + #[test] fn each_page_links_its_own_artboard_objects() { let mut d = doc(200.0, 100.0); diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index 0ab4d58c18..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<>]") @@ -278,6 +308,60 @@ fn spot_gradients_keep_a_trace_of_process_ink_at_a_light_stop() { } } +/// [`gold_gradient_pdf`] with spot swatch "Leaf" (C100 Y100) in the document too. +fn gold_and_leaf_gradient_pdf(stops: Vec, perceptual: bool) -> String { + 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 }); + d.swatches.push(Swatch { name: "Leaf".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 1.0, 0.0)), global: true, spot: true }); + let mut g = Gradient::new(GradientKind::Linear, stops); + if perceptual { + g.method = GradientMethod::Perceptual; + } + let paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + 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(paint, Paint::None, 0.0)); + d.insert(Some(l), 0, n).unwrap(); + String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned() +} + +#[test] +fn gradients_name_only_the_spot_inks_of_stops_that_color_what_draws() { + let black = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 1.0)) }; + let paper = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 0.0)); + let leaf = |offset: f32, tint: f32| { + let mut s = GradientStop::new(offset, Color::cmyk(1.0, 0.0, 1.0, 0.0).tinted(tint)); + s.swatch = Some("Leaf".into()); + s.tint = tint; + s + }; + let clear_gold = GradientStop { opacity: 0.0, ..gold_stop(0.4, 1.0) }; + for perceptual in [false, true] { + // Gold in a stretch where every stop is clear, and Gold out of order, where the stops at 0 + // and 0.8 cover the stretches next to it: Gold colors nothing, so no Gold plate. + let clear = vec![black(0.0, 1.0), black(0.2, 0.0), clear_gold.clone(), black(0.6, 0.0), black(1.0, 1.0)]; + let out_of_order = vec![black(0.0, 1.0), black(0.8, 1.0), gold_stop(0.2, 1.0), black(0.4, 1.0), black(1.0, 1.0)]; + // Gold at a tint of 0 prints no Gold ink. + let no_tint = vec![gold_stop(0.0, 0.0), black(1.0, 1.0)]; + for (what, stops) in [("clear", clear), ("out of order", out_of_order), ("tint 0", no_tint)] { + let text = gold_gradient_pdf(stops, perceptual); + assert!(!text.contains("/Gold"), "{what}, perceptual {perceptual}: {text}"); + assert!(!text.contains("/DeviceN") && !text.contains("/Separation"), "{what}, perceptual {perceptual}: {text}"); + } + // Without the Gold stop out of order, Leaf's tints over paper are a Separation shading. + let leaf_only = vec![leaf(0.0, 1.0), leaf(0.8, 0.5), gold_stop(0.2, 1.0), paper(0.4), paper(1.0)]; + let text = gold_and_leaf_gradient_pdf(leaf_only, perceptual); + assert!(text.contains("[/Separation/Leaf/") && !text.contains("/Gold") && !text.contains("/DeviceN"), "perceptual {perceptual}: {text}"); + // A clear Gold stop next to an opaque one colors the stretch between them. + let text = gold_gradient_pdf(vec![black(0.0, 1.0), clear_gold.clone(), black(1.0, 1.0)], perceptual); + assert!(text.contains("[/DeviceN[/Black/Gold]"), "perceptual {perceptual}: {text}"); + } + // Gold at a tint of 0 over a 30% cyan plate (a DeviceN gradient read from a PDF) keeps its + // process plate in the same space. + let plated = GradientStop { plates: Some([0.3, 0.0, 0.0, 0.0]), ..gold_stop(1.0, 0.0) }; + let text = gold_gradient_pdf(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), plated], false); + assert!(text.contains("[/DeviceN[/Cyan/Gold]"), "{text}"); +} + #[test] fn converting_to_rgb_turns_cmyk_into_rg() { let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 5c5e1eb2c0..2cb63e374a 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; @@ -451,6 +451,29 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() { assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}"); } +#[test] +fn symbol_art_effects_print_where_they_draw() { + // A red square and the symbol's blue square, each moved 100 pt right by a Transform effect, + // print at the same distance from the page's left edge. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"moveH": 100.0}), visible: true }); + n + }; + let mut d = doc(200.0, 100.0, &[]); + let (layer, id) = (d.default_layer().unwrap(), d.alloc_id()); + let red = Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0)); + d.insert(Some(layer), 0, moved(red)).unwrap(); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0)); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Moved".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let p = pages(&job(&d, json!({})).bytes); + assert_eq!(p.len(), 1); + let left = |c: [u8; 3]| p[0].1.iter().find(|(_, paint)| rgb8(paint) == c).map(|(r, _)| r.x0); + let (red, blue) = (left([255, 0, 0]), left([0, 0, 255])); + assert!(red.zip(blue).is_some_and(|(r, b)| (r - b).abs() < 0.05), "{p:?}"); +} + #[test] fn copies_collate_and_reverse() { let mut d = @@ -536,6 +559,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/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 3e844415a7..192ed24a96 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -951,7 +951,8 @@ fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> { } /// `p` without swatch links: a plug-in's colours are its own, so only paints it returned -/// unchanged keep theirs. +/// unchanged keep theirs. A gradient stop also loses its process plates, which print only with +/// a spot swatch. fn unlinked(mut p: Paint) -> Paint { match &mut p { Paint::Solid { swatch, tint, .. } => { @@ -961,8 +962,7 @@ fn unlinked(mut p: Paint) -> Paint { Paint::Gradient(g) => { g.swatch = None; for s in &mut g.gradient.stops { - s.swatch = None; - s.tint = 1.0; + s.set_color(s.color, None); } } Paint::None | Paint::Pattern { .. } => {} diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 3483971355..5fb48cf396 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -109,27 +109,81 @@ fn only_a_radial_keeps_a_focal_point_through_a_plug_in() { } } -/// A gradient stop's process plates handed back out of range are clamped to 0..1, each in its +/// A gradient stop's process plates handed back out of range are read clamped to 0..1, each in its /// place, in a fill and in a stroke (a JSON number too large for `f32` is infinite); plates in range /// and a stop without plates are kept. #[test] fn gradient_stop_plates_are_clamped_through_a_plug_in() { use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; - let (mut d, ids, _) = doc(); + let (_, ids, _) = doc(); let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(0.5, Color::gray(0.5)), GradientStop::new(1.0, Color::BLACK)]; let mut p = serde_json::to_value(Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))))).unwrap(); - p["gradient"]["stops"][0]["plates"] = json!([-0.5, 1.5, -1e300, 1e300]); - p["gradient"]["stops"][1]["plates"] = json!([0.25, 2.0, 0.75, -3.0]); + for (i, plates) in [(0, json!([-0.5, 1.5, -1e300, 1e300])), (1, json!([0.25, 2.0, 0.75, -3.0]))] { + p["gradient"]["stops"][i]["plates"] = plates; + p["gradient"]["stops"][i]["swatch"] = json!("Spot"); + } let out = json!({"objects": [{"id": ids[0].0, "fills": [p.clone()], "strokes": [{"paint": p}]}]}); - apply_output(&mut d, &ids, decode_json(out).unwrap(), None, &keep).unwrap(); - let ap = &d.node(ids[0]).unwrap().appearance; - for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] { + let out = decode_json(out).unwrap(); + let fill = &out[0].fills.as_ref().unwrap()[0]; + let stroke = &out[0].strokes.as_ref().unwrap()[0].paint; + for (what, paint) in [("fill", fill), ("stroke", stroke)] { let Paint::Gradient(g) = paint else { panic!("a gradient {what}") }; let plates: Vec<_> = g.gradient.stops.iter().map(|s| s.plates).collect(); assert_eq!(plates, [Some([0.0, 1.0, 0.0, 1.0]), Some([0.25, 1.0, 0.75, 0.0]), None], "{what}"); } } +/// A gradient linked to a gradient swatch, whose first stop is linked to a spot swatch at a tint +/// with process plates. +fn spot_gradient(color: Color) -> Paint { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let mut spot = GradientStop::new(0.0, color); + spot.midpoint = 0.3; + spot.opacity = 0.75; + spot.set_color(color, Some(("Spot".into(), 0.4))); + spot.plates = Some([0.1, 0.2, 0.3, 0.4]); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![spot, GradientStop::new(1.0, Color::BLACK)])); + g.swatch = Some("Blend".into()); + Paint::Gradient(Box::new(g)) +} + +/// A changed gradient handed back loses each stop's spot link, tint and process plates, in a fill +/// and in a stroke, and keeps the stop's color, offset, opacity and midpoint; a new stop handed back +/// with plates and no link has none. +#[test] +fn changed_gradient_fill_and_stroke_drop_spot_plates() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + let was = Color::cmyk(0.0, 0.5, 1.0, 0.0); + d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(was)); + d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(was)); + let now = Color::cmyk(0.0, 0.25, 0.5, 0.0); + let mut p = serde_json::to_value(spot_gradient(now)).unwrap(); + p["gradient"]["stops"][1]["plates"] = json!([0.5, 0.5, 0.5, 0.5]); + let out = json!({"objects": [{"id": a.0, "fills": [p.clone()], "strokes": [{"paint": p}]}]}); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap(); + let ap = &d.node(a).unwrap().appearance; + for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] { + let Paint::Gradient(g) = paint else { panic!("a gradient {what}") }; + assert_eq!(g.swatch, None, "{what}"); + let stops: Vec<_> = g.gradient.stops.iter().map(|s| (s.offset, s.color, s.opacity, s.midpoint, s.swatch.clone(), s.tint, s.plates)).collect(); + assert_eq!(stops, [(0.0, now, 0.75, 0.3, None, 1.0, None), (1.0, Color::BLACK, 1.0, 0.5, None, 1.0, None)], "{what}"); + } +} + +/// A gradient handed back as it was keeps its stops' spot links, tints and process plates. +#[test] +fn unchanged_gradient_paint_keeps_spot_plates() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0))); + d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0))); + let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap(); + let before = d.clone(); + apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &keep).unwrap(); + assert_eq!(d, before); +} + /// A plate that is not a number is 0; infinities go to the nearest end of 0..1. #[test] fn plates_that_are_not_finite_are_in_range() { diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index ce76713e74..65ce8a91fd 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -623,7 +623,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); let b = if art.clips() { - art.visual_bounds_with(symbols) + art.visual_bounds_with(symbols).map(|b| with_clip_region(symbols, art, b)) } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c))) }?; @@ -671,7 +671,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let b = n.visual_bounds_with(symbols)?; let b = match &n.kind { NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), - _ => b, + _ => with_clip_region(symbols, n, b), }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) @@ -679,6 +679,24 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b } } +/// `b`, the visual bounds of `n`, grown to the region a clip group's or clipping layer's clipping +/// path clips to when geometry effects in it move or grow that region ([`effects::clip_outline`]) +/// past its plain shape, and to where its stroke paints along it. Other objects keep `b`. +fn with_clip_region(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect { + match &n.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() { + Some(c) if effects::needs_bake(c) => { + let s = c.appearance.outset(); + let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s)); + let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None }; + vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b) + } + _ => b, + }, + _ => b, + } +} + /// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate), /// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no /// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps 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/paint.rs b/crates/render/src/paint.rs index 1d39ece10c..c130fbe346 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -1,6 +1,7 @@ //! Paint conversion (solid, gradients, patterns) and image decoding. use vectorcraft_color::{GradientKind, Paint}; +use vectorcraft_doc::resample::{Resample, resized}; use vectorcraft_geom::{Rect, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko::{self, ColorStop}; @@ -446,7 +447,7 @@ fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage { } let s = max as f64 / w.max(h) as f64; let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max)); - img.resize_exact(nw, nh, image::imageops::FilterType::Triangle) + resized(img, nw, nh, Resample::Filter(image::imageops::FilterType::Triangle)) } /// [`decode_pixmap_or_why`]'s pixels. @@ -504,7 +505,7 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option w as u32 || img.height() > h as u32 { - img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle) + resized(img, w as u32, h as u32, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img }; @@ -587,4 +588,35 @@ mod tests { assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC + 1), Err(Undecoded::NoRoom)); assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(())); } + + /// Opaque red beside clear black, every other column of a `w` × 1 image. + fn striped(w: u32) -> image::RgbaImage { + image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + } + + /// Does each pixel of `pm` hold red at its own coverage (premultiplied, within a step)? + fn red_at_its_coverage(pm: &vello_cpu::Pixmap) -> bool { + let m = |c: u8, a: u8| ((u16::from(c) * u16::from(a) + 127) / 255) as u8; + pm.data().iter().all(|p| p.r.abs_diff(m(200, p.a)) <= 1 && p.g.abs_diff(m(30, p.a)) <= 1 && p.b.abs_diff(m(30, p.a)) <= 1) + } + + /// A placed image drawn smaller keeps the color of its opaque pixels at its cut-out edges: the + /// clear pixels beside them do not darken them. + #[test] + fn an_image_drawn_smaller_keeps_its_color_beside_clear_pixels() { + let mut png = Vec::new(); + striped(8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let pm = decode_pixmap_fit(&png, 2, 1).unwrap(); + assert_eq!((pm.width(), pm.height()), (2, 1)); + assert!(pm.data().iter().all(|p| p.a > 0)); + assert!(red_at_its_coverage(&pm), "{:?}", pm.data()); + } + + /// An image wider than a pixmap may be is shrunk to fit with its colors weighted by alpha the same way. + #[test] + fn an_image_shrunk_to_fit_a_pixmap_keeps_its_color_beside_clear_pixels() { + let pm = decoded_pixmap(image::DynamicImage::ImageRgba8(striped(u32::from(u16::MAX) + 4465))).unwrap(); + assert_eq!(pm.width(), u16::MAX); + assert!(red_at_its_coverage(&pm)); + } } 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/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index f649453663..15bd354496 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -122,7 +122,8 @@ impl Renderer { /// When a plug-in has been installed or removed since the last check /// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect /// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one, - /// of symbol instances and of the objects that hold them, and the art drawn for the effects of + /// of symbol instances and of the objects that hold them, the regions of clipping paths that + /// have one or hold one, and the art drawn for the effects of /// objects that have a plug-in effect or hold one. Rasters of raster effects and evaluations of live objects hold /// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`, /// `live::resources_key`). @@ -148,6 +149,8 @@ impl Renderer { self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); // The art of a group holds what its members' plug-in effects draw. self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); + // A clipping path's plug-in effects shape the region it clips to. + self.clips.retain(|(node, _)| !holds_plugin_effect(node)); } } diff --git a/crates/render/src/tests/clip.rs b/crates/render/src/tests/clip.rs index 7b04c02e2d..205c2a2583 100644 --- a/crates/render/src/tests/clip.rs +++ b/crates/render/src/tests/clip.rs @@ -95,6 +95,79 @@ fn nothing_to_clip_by_hides_the_art() { assert_eq!(r.pixel(50, 50), CLEAR); } +#[test] +fn the_clipping_paths_geometry_effects_shape_the_clip() { + // A Transform effect moving the left half 50 pt right: the right half shows instead. + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }); + let r = clipped(d, Arc::new(clip)); + assert_eq!(r.pixel(25, 50), CLEAR, "the clipping path's own place"); + assert_eq!(r.pixel(75, 50), BLACK, "where its effect moves it"); +} + +#[test] +fn a_view_of_only_the_moved_clip_draws_the_clipped_art() { + // The clipping path covers x 0 to 50 and its Transform effect moves it to x 50 to 100; the + // view shows x 60 to 100, past the clipping path's own shape. + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }); + let art = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let members = vec![Arc::new(clip), Arc::new(art)]; + let g = Node::new(d.alloc_id(), NodeKind::Group { children: members.clone(), clip: true }); + // The same in a sublayer that clips by its first object. + let mut sublayer = (*d.layers[0]).clone(); + sublayer.id = d.alloc_id(); + if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind { + (*children, *clip) = (members, true); + } + for clipping in [g, sublayer] { + let mut d = d.clone(); + let l = d.layers[0].id; + d.insert(Some(l), 0, clipping).unwrap(); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip"); + } +} + +#[test] +fn a_view_of_only_the_moved_clip_draws_a_clip_group_with_its_own_appearance() { + // As above, with the Transform effect on the clipping path itself or on its fill, and the clip + // group with an effect of its own: a Drop Shadow, or a Transform that moves nothing. + let moved = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }; + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 0, "y": 6, "blur": 0, "opacity": 100}), + visible: true, + }; + let still = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveV": 0}), visible: true }; + for on_fill in [false, true] { + for own in [shadow.clone(), still.clone()] { + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + match clip.appearance.items.iter_mut().find_map(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) if on_fill => Some(f), + _ => None, + }) { + Some(f) => f.effects.push(moved()), + None => clip.appearance.effects.push(moved()), + } + let art = + Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + g.appearance.effects.push(own.clone()); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let what = format!("effect on the fill: {on_fill}, group effect {}", own.id); + let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(whole.pixel(80, 50), BLACK, "the whole canvas ({what})"); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip ({what})"); + } + } +} + #[test] fn clip_regions_are_cached_per_clipping_path() { let mut d = Document::new(100.0, 100.0); 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/render/src/tests/container.rs b/crates/render/src/tests/container.rs index 89126cf2a2..dfc8dca91c 100644 --- a/crates/render/src/tests/container.rs +++ b/crates/render/src/tests/container.rs @@ -217,9 +217,9 @@ fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geomet #[test] fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() { - // The red square is clipped by type with a Transform of its own, which the clip leaves out, - // then by a group of that type and a small square, with the type shown and hidden. Offset - // Path 0 on the clip group leaves every path as it is. + // The red square is clipped by type with a Transform of its own, then by a group of that type + // and a small square, with the type shown and hidden. Offset Path 0 on the clip group leaves + // every path as it is, so the clip group draws the same with it and without it. let doc = |effect: bool, grouped: bool, hidden: bool| { let mut d = Document::new(100.0, 100.0); let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() }; diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 1f7a49698c..a9226da834 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1667,6 +1667,16 @@ fn objects_with_plugin_effects_follow_the_plugins_installed() { plugin_change_draws_as_new(alone(20.0, vec![drop_shadow()]), ID, small.clone(), big.clone()); let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0})); plugin_change_draws_as_new(alone(20.0, vec![blur]), ID, small.clone(), big.clone()); + // A clip group whose clipping path, the square, has the plug-in effect: it clips a red + // rectangle over the whole view by the shape the plug-in draws. + let mut d = Document::new(200.0, 100.0); + let mut clip = square(&mut d, 20.0, 30.0, vec![]); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let red = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]); + let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(red)], clip: true }); + plugin_change_draws_as_new(with_art(d, vec![group]), ID, small.clone(), big.clone()); // An instance of symbol `Shaped`, a group of the square around the origin. let mut d = Document::new(200.0, 100.0); let art = square(&mut d, -20.0, -20.0, vec![]); diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 7708d23c06..36744c8de5 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -31,6 +31,8 @@ image = { workspace = true } # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" simplecss = "0.2.2" +# Bidi classes for the first strong character of `unicode-bidi: plaintext` text (MIT OR Apache-2.0; already used by vectorcraft-text). +unicode-bidi = "0.3.18" [dev-dependencies] # Reading embedded font subsets back. diff --git a/crates/svg/src/export/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/export/text.rs b/crates/svg/src/export/text.rs index 2b9139f19b..ccd5f56806 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -283,6 +283,11 @@ impl Writer<'_> { self.warn("type on a path with Insert Aki is outlined to keep its look"); return self.text_outlines(n, t); } + // Tsume does as well. + if t.runs.iter().any(|r| r.style.tsume != 0.0) { + self.warn("type on a path with Tsume is outlined to keep its look"); + return self.text_outlines(n, t); + } // A `` holds the characters of the text, not the bullets or numbers drawn // before them. if !lay.markers.is_empty() { @@ -715,13 +720,13 @@ const PLACE_TOLERANCE: f64 = 1e-6; /// starts a new segment at each character the layout draws elsewhere than a reader continuing /// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between /// the characters of a justified Japanese line, and the spacing Mojikumi changes. Text with Insert -/// Aki does so on every line, with `fewer` too. +/// Aki or Tsume does so on every line, with `fewer` too. fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { let plain = t.plain_text(); let mut lines = Vec::with_capacity(lay.lines.len()); let (mut kerned, mut unkerned) = (None, None); - // Insert Aki moves characters from where a reader draws them, with `fewer` too. - let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0); + // Insert Aki and Tsume move characters from where a reader draws them, with `fewer` too. + let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0 || r.style.tsume != 0.0); for line in &lay.lines { let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); let style = t.para_at(para); @@ -885,11 +890,12 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: if !kerning { r.style.kerning.get_or_insert(0.0); } - // A reader puts no space before a line's first character (`kerningBefore`) and no Insert - // Aki: the segment starts that much further along instead. + // A reader puts no space before a line's first character (`kerningBefore`), no Insert + // Aki and no Tsume: the segment starts that much further along instead. r.style.kerning_before = 0.0; r.style.aki_left = 0.0; r.style.aki_right = 0.0; + r.style.tsume = 0.0; } let relaid; let glyphs = if set == *t { diff --git a/crates/svg/src/import/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 1a221a69f1..cbffbfc348 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -40,7 +40,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, ParaStyle, StrokeLayer, TextKind, TextObject, TextRun}; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; -use vectorcraft_text::{FontDb, TextLayout}; +use vectorcraft_text::{FontDb, PositionedGlyph, TextLayout, spacing_scale}; use super::css::{Styles, XNode}; use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href, vector_effect}; @@ -711,9 +711,9 @@ struct Ctx { sizes: RefCell>, weights: RefCell>, elems: RefCell>>, - /// Whether the first strong character of each element read so far is right to left - /// ([`Self::first_strong_rtl`]); `None` where it holds none. - strong: RefCell>>, + /// The first strong characters of each `` read so far, by the `` + /// ([`Self::first_strong_rtl`]). + strong: RefCell>>, /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has /// no usable one. The styles of a `` instance share it, so each path is found and parsed /// once however many `` elements show its text. @@ -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)), @@ -1006,22 +1012,39 @@ impl Ctx { } /// Whether the first strong character (UAX #9 P2) of the characters [`Self::collect`] reads - /// from element `n` is right to left; `None` when they hold none. Each element is read once. + /// from element `n` is right to left; `None` when they hold none. The characters of the whole + /// `` are read once, as one sequence, so an isolate that opens in one text node and + /// closes in a later one is skipped whole ([`FirstStrong`]). fn first_strong_rtl(&self, n: XNode) -> Option { - if let Some(s) = self.strong.borrow().get(&n.id()) { - return *s; + // The `` (or outermost element) whose characters hold those of `n`. + let mut root = n; + while let Some(p) = root.parent_element().filter(|_| root.tag_name().name() != "text" && self.is_inline_child(root)) { + root = p; } - let s = n.children().find_map(|c| { - if c.is_text() { - text_first_strong_rtl(c.text().unwrap_or("")) - } else if self.is_inline_child(c) { - self.first_strong_rtl(c) - } else { - None - } + let strong = self.strong.borrow().get(&root.id()).cloned(); + let strong = strong.unwrap_or_else(|| { + let mut chars = vec![]; + let mut spans = HashMap::new(); + self.inline_chars(root, &mut chars, &mut spans); + let s = Rc::new(FirstStrong::new(&chars, spans)); + self.strong.borrow_mut().insert(root.id(), s.clone()); + s }); - self.strong.borrow_mut().insert(n.id(), s); - s + strong.of(n.id()) + } + + /// Appends the characters of element `n` and of its shown inline children to `chars`, in + /// document order, and the range each of those elements holds to `spans`. + fn inline_chars(&self, n: XNode, chars: &mut Vec, spans: &mut HashMap>) { + let start = chars.len(); + for c in n.children() { + if c.is_text() { + chars.extend(c.text().unwrap_or("").chars()); + } else if self.is_inline_child(c) { + self.inline_chars(c, chars, spans); + } + } + spans.insert(n.id(), start..chars.len()); } /// Whether child `c` of a `` content element holds characters of the text: a shown @@ -1359,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 { @@ -1386,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); @@ -1408,10 +1431,18 @@ impl Ctx { let em = self.font_size(tp); let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0]; let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 }; - // Text that would start before the path's beginning starts at it, with all its glyphs - // on the path (SVG does not draw the glyphs that fall off it). - let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0)); - obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None }; + // On a path of one closed subpath, text that starts before the path's start or past its + // end wraps around the path. Otherwise it starts at the path's start or end. + let path = PathData::from_bezpath(&bp); + let wraps = path.subpaths.len() == 1 && path.is_closed(); + let wrap = |s: f64| { + // `rem_euclid` can round a tiny negative value up to 1. + let s = s.rem_euclid(1.0); + if s < 1.0 { s } else { 0.0 } + }; + let start = + Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) }); + obj.kind = TextKind::OnPath { path, start, end: None }; obj.xf = Affine::IDENTITY; } else if vertical { // Type on a vertical path: dy is extra advance, dx shifts across the column. @@ -1435,7 +1466,12 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks); + let (justify, left, starts, unplaced) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks); + if unplaced > 0 { + warnings.push(format!( + "{label}: {unplaced} absolutely placed character(s) between characters of the other direction keep their place in the line" + )); + } obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); // Lines anchored differently or at different x (paragraphs aligned or indented @@ -1568,7 +1604,8 @@ fn leading_tolerance(spacing: f64) -> f64 { /// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk /// starting at a character sits on its anchor, and `pin` sets the paragraph directions of the /// text ([`pin_direction`]). When characters are placed on their own, every line is left-aligned -/// and its anchor x is its left edge. +/// and its anchor x is its left edge. Also returns how many absolutely placed characters kerning +/// cannot put on their x. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1580,7 +1617,7 @@ fn lines( pin: &dyn Fn(&mut TextObject), x0: f64, breaks: &mut Vec, -) -> (Justify, f64, Vec<(usize, f64, f64)>) { +) -> (Justify, f64, Vec<(usize, f64, f64)>, usize) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -1623,7 +1660,7 @@ fn lines( } if targets.is_empty() { let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect(); - return (justify_of(anchor_of(0)), x0, starts); + return (justify_of(anchor_of(0)), x0, starts, 0); } // Absolutely placed characters: lay the text out left-aligned and kern each one onto its x. // SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left @@ -1633,59 +1670,122 @@ fn lines( // Chunk starts in order: line starts and absolutely placed characters. let mut starts: Vec = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect(); starts.sort_unstable(); - // The left and right edges of the chunk starting at character `s`, from each character's pen and end. - let edges = |s: usize, pens: &[Option], ends: &[Option]| { + // The left and right edges of the chunk starting at character `s`, and the character drawn at its left. + let edges = |s: usize, pens: &[Option]| { let e = starts.get(starts.partition_point(|&c| c <= s)).copied().unwrap_or(n); - let left = (s..e).filter_map(|j| pens[j]).fold(f64::INFINITY, f64::min); - let right = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max); - (left.is_finite() && right.is_finite()).then_some((left, right)) + let (mut left, mut right, mut at) = (f64::INFINITY, f64::NEG_INFINITY, s); + for (j, p) in pens.iter().enumerate().take(e).skip(s) { + let Some(p) = p else { continue }; + if p.start < left { + (left, at) = (p.start, j); + } + right = right.max(p.end); + } + (left.is_finite() && right.is_finite()).then_some((left, right, at)) }; let mut line_left: Vec = lines.iter().map(|l| l.1).collect(); + // Where each line's first chunk's left edge goes. + let mut first_left: Vec = vec![]; // Where each absolutely placed character's pen goes, from the left edge of its line's first chunk. let mut wants: Vec<(usize, f64)> = vec![]; + // Absolutely placed characters drawn right next to a placed neighbor with no kerning between + // them (a left-to-right character between right-to-left ones): they stay where the layout puts them. + let mut unplaced = 0; // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. for round in 0..4 { let (runs, bytes) = assemble(cells, breaks); let mut text = with_runs(runs.0); pin(&mut text); - let (pens, ends) = pens(&measure(&text), &bytes); + let pens = pens(&measure(&text), &bytes, cells); // The edges of each line's first chunk, found once a round. - let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect(); + let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens)).collect(); if round == 0 { // The chunks as they lie before any is moved: each one's left edge on its anchor. - let left_at = |s: usize, x: f64, chunk: Option<(f64, f64)>| chunk.map_or(x, |(left, right)| x - anchor_of(s) * (right - left)); - let first_left: Vec = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect(); + let left_at = |s: usize, x: f64, chunk: Option<(f64, f64, usize)>| chunk.map_or(x, |(left, right, _)| x - anchor_of(s) * (right - left)); + first_left = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect(); wants = targets .iter() .map(|&(i, x)| { - let chunk = edges(i, &pens, &ends); + let chunk = edges(i, &pens); // The pen of a right-to-left chunk's first character is right of the chunk's left edge. - let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, _))| p - left); + let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, ..))| p.start - left); (i, left_at(i, x, chunk) + pen - first_left[line_of(i)]) }) .collect(); - // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` from there. - line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, _)| left)).collect(); } - let (mut line, mut moved, mut worst) = (usize::MAX, 0.0, 0.0f64); - for &(i, want) in &wants { - let l = line_of(i); - if l != line { - (line, moved) = (l, 0.0); + // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` + // from there (right-to-left kerning can move that chunk). + line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, ..)| left)).collect(); + // Kerning a character widens its advance to the right: the glyphs drawn right of it move + // right, and so does a right-to-left glyph's own left edge (its kerning is at its left). In + // drawing order the kerning before an absolutely placed character is a cut after the + // character before it (before that character when it runs right to left), and what is drawn + // after the cut moves. Places in drawing order are doubled (from 2) to put cuts between them. + // Each gap between two placed characters drawn one after the other takes the kerning of one + // cut in it, so placing a character moves none placed before it. Kerning is drawn at the + // character's horizontal scale, so each correction is divided by it. + let mut worst = 0.0f64; + let mut from = 0; + unplaced = 0; + while from < wants.len() { + let l = line_of(wants[from].0); + let to = from + wants[from..].partition_point(|&(i, _)| line_of(i) == l); + let on_line = &wants[from..to]; + from = to; + let Some((p0, _, at)) = first[l] else { continue }; + let Some(home) = pens[at].map(|p| 2 * p.rank + 2) else { continue }; + // A character drawn as part of the glyph before it (a mark) is kerned where that glyph is. + // The kerning stays on the character just before the placed one: a mark whose kerning + // differs from its base's is drawn as a glyph of its own, and kerning the base would put + // the space between the base and its mark. + // Each search stops at the nearest earlier character with a glyph, at the latest the + // placed character before it, so the searches of a line read each character once. + let mut cuts: Vec<(usize, usize)> = on_line + .iter() + .filter(|&&(i, _)| pens[i].is_some()) + .filter_map(|&(i, _)| (lines[l].0..i).rev().find_map(|j| pens[j]).map(|k| (2 * k.rank + if k.rtl { 1 } else { 3 }, i - 1))) + .collect(); + cuts.sort_unstable(); + let mut points: Vec<(usize, f64, f64)> = + on_line.iter().filter_map(|&(i, want)| pens[i].map(|p| (2 * p.rank + 2, p.start, want))).collect(); + points.sort_by_key(|p| p.0); + // The character kerned at the first cut between places `a` and `b`. + let cut = |a: usize, b: usize| cuts.get(cuts.partition_point(|c| c.0 <= a)).filter(|c| c.0 < b).map(|c| c.1); + let split = points.partition_point(|p| p.0 < home); + // Drawn right of the line's first chunk: kerning moves the character away from it. + let (mut prev, mut moved) = (home, 0.0); + for &(place, start, want) in &points[split..] { + if let Some(k) = cut(prev, place) { + let d = want - (start - p0 + moved); + cells[k].kern += d / spacing_scale(&cells[k].style, false); + moved += d; + worst = worst.max(d.abs()); + } else { + unplaced += 1; + } + prev = place; + } + // Drawn left of it: kerning moves the chunk (and what lies between) away from the character. + (prev, moved) = (home, 0.0); + for &(place, start, want) in points[..split].iter().rev() { + if let Some(k) = cut(place, prev) { + let d = start - p0 - moved - want; + cells[k].kern += d / spacing_scale(&cells[k].style, false); + moved += d; + worst = worst.max(d.abs()); + } else { + unplaced += 1; + } + prev = place; } - let (Some(p), Some((p0, _))) = (pens[i], first[l]) else { continue }; - let d = want - (p - p0 + moved); - cells[i - 1].kern += d; - moved += d; - worst = worst.max(d.abs()); } if worst < 0.01 { break; } } let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect(); - (Justify::Left, line_left[0], starts) + (Justify::Left, line_left[0], starts, unplaced) } /// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` @@ -1774,6 +1874,56 @@ fn text_first_strong_rtl(text: &str) -> Option { (!vectorcraft_text::paragraph_is_rtl(&format!("{line}\u{5D0}"), None)).then_some(false) } +/// The first strong characters (UAX #9 P2) of the elements of one ``, found for each element +/// in constant time from one pass over the text's characters. An isolate initiator is skipped to +/// its matching PDI (UAX #9 BD9), and one without a matching PDI to the end; paragraph separators +/// count as spaces, as in [`text_first_strong_rtl`]. +struct FirstStrong { + /// The characters each element holds: a range of indices into `next`. + spans: HashMap>, + /// For each character and for the end: the index of the first strong character from there on, + /// isolates skipped, and whether it is right to left; `None` when there is none. + next: Vec>, +} + +impl FirstStrong { + fn new(chars: &[char], spans: HashMap>) -> Self { + use unicode_bidi::BidiClass::{AL, FSI, L, LRI, PDI, R, RLI}; + let class = |c: char| unicode_bidi::bidi_class(c); + // The matching PDI of each isolate initiator. + let mut pdi = vec![None; chars.len()]; + let mut open = vec![]; + for (i, &c) in chars.iter().enumerate() { + match class(c) { + LRI | RLI | FSI => open.push(i), + PDI => { + if let Some(at) = open.pop() { + pdi[at] = Some(i); + } + } + _ => {} + } + } + let mut next = vec![None; chars.len() + 1]; + for (i, &c) in chars.iter().enumerate().rev() { + next[i] = match class(c) { + L => Some((i, false)), + R | AL => Some((i, true)), + LRI | RLI | FSI => pdi[i].and_then(|p| next[p + 1]), + _ => next[i + 1], + }; + } + Self { spans, next } + } + + /// Whether the first strong character of element `n` is right to left; `None` when it holds + /// none. An isolate that opens inside `n` and closes after it hides the rest of `n`. + fn of(&self, n: roxmltree::NodeId) -> Option { + let span = self.spans.get(&n)?; + self.next.get(span.start).copied().flatten().filter(|&(at, _)| at < span.end).map(|(_, rtl)| rtl) + } +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); @@ -1794,13 +1944,40 @@ fn advance(t: &TextObject) -> f64 { measure(t).glyphs.iter().map(|g| g.advance).sum() } -/// Pen x (start) and end x of the glyph that starts at each character's byte offset. -fn pens(lay: &TextLayout, bytes: &[usize]) -> (Vec>, Vec>) { - let mut at: HashMap = HashMap::with_capacity(lay.glyphs.len()); - for g in &lay.glyphs { - at.entry(g.byte).or_insert((g.origin.x, g.origin.x + g.advance)); +/// Where the glyph that starts a character is drawn on a line of point type. +#[derive(Clone, Copy)] +struct Pen { + /// Its left edge. Manual kerning on a right-to-left glyph is at its left, and the glyph is + /// drawn right of it: its left edge is past that kerning. + start: f64, + /// The right edge of its advance. + end: f64, + /// Its place in the order its line is drawn in, from the left. + rank: usize, + rtl: bool, +} + +/// The [`Pen`] of the glyph that starts at each character's byte offset (`bytes`, parallel to `cells`). +fn pens(lay: &TextLayout, bytes: &[usize], cells: &[Cell]) -> Vec> { + // A line's glyphs are laid out in the order they are drawn. + let mut at: HashMap = HashMap::with_capacity(lay.glyphs.len()); + for (rank, g) in lay.glyphs.iter().enumerate() { + at.entry(g.byte).or_insert((rank, g)); } - bytes.iter().map(|b| at.get(b).map_or((None, None), |&(s, e)| (Some(s), Some(e)))).unzip() + bytes + .iter() + .zip(cells) + .map(|(b, c)| { + let &(rank, g) = at.get(b)?; + let mut start = g.origin.x; + if g.rtl { + let st = c.final_style(); + let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(&st, false); + start += finite(kern); + } + Some(Pen { start, end: g.origin.x + g.advance, rank, rtl: g.rtl }) + }) + .collect() } type Key = (Option, Option); 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/effects_export.rs b/crates/svg/tests/it/effects_export.rs index 832e72c8f8..c1a19e8d53 100644 --- a/crates/svg/tests/it/effects_export.rs +++ b/crates/svg/tests/it/effects_export.rs @@ -102,6 +102,37 @@ fn a_strokes_own_glow_filters_the_stroke_element_only() { assert!(!export(&doc_with(n), &ExportOptions::default()).contains("").expect("its end")]; + body[body.find('>').unwrap()..].to_string() + }; + assert_eq!(clipped(0.0, true), clipped(50.0, false), "the clip path is written where the effect moves it"); + assert_ne!(clipped(0.0, true), clipped(0.0, false)); +} + #[test] fn geometry_effects_on_type_export_as_its_reshaped_outlines() { let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; 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 472d7517e4..816e17f15d 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{GradientKind, Paint}; use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_svg::{import, import_with_report}; -use vectorcraft_text::{FontDb, layout}; +use vectorcraft_text::{FontDb, layout, spacing_scale}; fn svg(body: &str) -> String { format!(r#"{body}"#) @@ -299,6 +299,84 @@ fn text_path_becomes_type_on_a_path() { assert_eq!(t[2].para.justify, Justify::Left); } +#[test] +fn text_on_a_closed_path_wraps_past_the_path_start() { + // A closed path 400 long: text anchored before its start, or offset past its end, wraps + // around the path instead of stopping at its start or end. + let d = open( + r##" + Wave + Wave + Wave + Wave + Wave + Wave + + Wave + + Wave + Wave"##, + ); + let t = texts(&d); + assert_eq!(t.len(), 9); + let start_of = |t: &TextObject| match t.kind { + TextKind::OnPath { start, .. } => start, + _ => panic!("{:?}", t.kind), + }; + let width: f64 = layout(FontDb::global(), t[0]).glyphs.iter().map(|g| g.advance).sum(); + assert!(width > 0.0); + // Ending at the path's start: the text starts its width before the end of the loop. + assert!(close(start_of(t[0]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[0])); + assert!(close(start_of(t[1]), 0.1, 1e-9), "{}", start_of(t[1])); + // Right-to-left text starts at its edge toward the path's end, here the path's start. + assert!(close(start_of(t[2]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[2])); + // Centered on the path's start: half the text before it, half after. + assert!(close(start_of(t[3]), 1.0 - width / 800.0, 1e-6), "{} {width}", start_of(t[3])); + // A negative startOffset, in user units or as a percentage, counts back from the path's end. + assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4])); + assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5])); + // A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's + // start or end. + assert_eq!(start_of(t[6]), 0.0); + assert_eq!(start_of(t[7]), 0.0); + assert_eq!(start_of(t[8]), 1.0); + + // Laid out, the end-anchored text draws every glyph up the left side and ends at the start + // point; the centered text crosses the start point from the left side onto the top. + let lay = layout(FontDb::global(), t[0]); + assert!(!lay.overflow); + assert_eq!(lay.glyphs.len(), 4); + for g in &lay.glyphs { + let o = t[0].xf * g.origin; + assert!(close(o.x, 50.0, 0.05) && o.y > 50.0 && o.y < 150.0, "{o:?}"); + } + let last = lay.glyphs.last().map(|g| t[0].xf * (g.origin + baseline_dir(g.angle) * g.advance)); + assert!(last.is_some_and(|p| close(p.x, 50.0, 0.05) && close(p.y, 50.0, 0.05)), "{last:?}"); + let lay = layout(FontDb::global(), t[3]); + assert!(!lay.overflow); + assert_eq!(lay.glyphs.len(), 4); + let first = t[3].xf * lay.glyphs[0].origin; + assert!(close(first.x, 50.0, 0.05) && first.y > 50.0, "{first:?}"); + let last = t[3].xf * lay.glyphs[3].origin; + assert!(close(last.y, 50.0, 0.05) && last.x > 50.0, "{last:?}"); + + // Text longer than the loop draws its first characters, whatever its anchor. + let d = open( + r##" + Wave Wave Wave"##, + ); + let t = only_text(&d); + let lay = layout(FontDb::global(), t); + assert!(lay.overflow); + assert!(!lay.glyphs.is_empty() && lay.glyphs.len() < 14, "{}", lay.glyphs.len()); + assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::>()); +} + +/// The unit vector along a glyph's baseline. +fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 { + vectorcraft_geom::Vec2::new(angle.cos(), angle.sin()) +} + #[test] fn per_character_positions_rotation_and_shifts() { let d = open(r#"abcd"#); @@ -391,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), @@ -494,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!( @@ -503,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:?}"); @@ -587,6 +670,12 @@ fn unicode_bidi_values_the_text_model_cannot_hold_are_reported() { (r#"אבab"#.to_string(), "ab", Ltr), (format!("{far}"), "\u{5D0}", Rtl), (format!("\n12 {far}"), "\u{5D0}", Rtl), + // An isolate that opens in one text node and closes in a later one is skipped whole. + ("⁧ab⁩א".to_string(), "\u{5D0}", Rtl), + ("⁧abcd⁩א".to_string(), "\u{5D0}", Rtl), + // An isolate that opens in a nested `plaintext` element and closes after it hides the rest of that element. + (r#"⁧ab⁩א"#.to_string(), "\u{5D0}", Rtl), + (r#"⁧ab⁩א"#.to_string(), "ab", Ltr), ] { let (d, _) = import_with_report(&svg(&format!(r#"x {body}"#))).unwrap(); @@ -719,6 +808,114 @@ fn absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edg } } +/// Document-space left and right edges of the natural advance of the characters in `bytes` (the +/// manual kerning that places the next chunk left out: it is at a glyph's right when the glyph runs +/// left to right and at its left when it runs right to left). +fn chunk_edges(t: &TextObject, bytes: std::ops::Range) -> (f64, f64) { + let lay = layout(FontDb::global(), t); + let mut starts = vec![]; + let mut at = 0; + for r in &t.runs { + starts.push((at, &r.style)); + at += r.text.len(); + } + let style = |byte: usize| starts.iter().rev().find(|(s, _)| *s <= byte).map(|(_, st)| *st).unwrap(); + let (mut left, mut right) = (f64::INFINITY, f64::NEG_INFINITY); + for g in lay.glyphs.iter().filter(|g| bytes.contains(&g.byte)) { + let st = style(g.byte); + let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(st, false); + let x = (t.xf * g.origin).x; + let (l, r) = if g.rtl { (x + kern, x + g.advance) } else { (x, x + g.advance - kern) }; + (left, right) = (left.min(l), right.max(r)); + } + assert!(left.is_finite(), "no glyph in {bytes:?}"); + (left, right) +} + +/// SVG 1.1 §10.4 and SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): +/// every absolutely placed character starts a chunk anchored on its own x, also in right-to-left +/// text whose characters run right to left, on any line. +#[test] +fn absolutely_placed_right_to_left_characters_sit_on_their_own_x() { + // (markup, share of each chunk's advance left of its x, each chunk's bytes and x) + let two_lines = |anchor: &str| { + format!( + r#"abאב"# + ) + }; + type Chunks = Vec<(std::ops::Range, f64)>; + let cases: Vec<(String, f64, Chunks)> = vec![ + (two_lines("start"), 1.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (two_lines("middle"), 0.5, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (two_lines("end"), 0.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (r#"אב"#.into(), 1.0, vec![(0..2, 100.0), (2..4, 160.0)]), + (r#"אב"#.into(), 0.0, vec![(0..2, 100.0), (2..4, 160.0)]), + // Three lines, the characters of the last out of order and overlapping. + ( + r#"אבגדהוז"#.into(), + 1.0, + vec![(0..4, 300.0), (5..7, 60.0), (7..9, 120.0), (10..12, 200.0), (12..14, 90.0), (14..16, 205.0)], + ), + // Left-to-right characters among right-to-left ones on the same line. + (r#"אbc"#.into(), 1.0, vec![(0..2, 100.0), (2..3, 160.0), (3..4, 220.0)]), + // Chunks of several characters. + ( + r#"abאבגד"#.into(), + 1.0, + vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)], + ), + ( + r#"abאבגד"#.into(), + 0.5, + vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)], + ), + // A placed character after a letter with a combining mark. + ( + r#"אָב"#.into(), + 1.0, + vec![(4..6, 160.0)], + ), + (r#"éb"#.into(), 0.0, vec![(3..4, 160.0)]), + // Characters stretched by `lengthAdjust`, whose kerning is drawn at their scale. + ( + r#"abאב"#.into(), + 1.0, + vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)], + ), + // Left to right, the right-to-left characters still each start at their own x. + ( + r#"abאב"#.into(), + 0.0, + vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)], + ), + ( + r#"abcd"#.into(), + 0.0, + vec![(0..2, 20.0), (3..4, 100.0), (4..5, 160.0)], + ), + ]; + let unplaced = |w: &[String]| w.iter().any(|w| w.contains("absolutely placed")); + for (body, share, chunks) in cases { + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(!unplaced(&w), "{body}: {w:?}"); + let t = only_text(&d); + if body.contains("lengthAdjust") { + assert!(t.runs.iter().any(|r| r.style.h_scale < 99.0), "{body}: {:?}", t.runs); + } + for (bytes, x) in chunks { + let (left, right) = chunk_edges(t, bytes.clone()); + let want = x - share * (right - left); + assert!(close(left, want, 0.05), "{body}: the chunk {bytes:?} spans {left}..{right}, so it does not start at {want}"); + } + } + // A left-to-right character between right-to-left ones is drawn right next to the one after + // it, with no kerning between them: that one keeps its place in the line, and a warning says so. + let (d, w) = import_with_report(&svg(r#"אbג"#)).unwrap(); + assert!(unplaced(&w), "{w:?}"); + let (left, right) = chunk_edges(only_text(&d), 2..3); + assert!(close(right, 160.0, 0.05), "b spans {left}..{right}"); +} + #[test] fn a_small_line_gap_in_tiny_type_keeps_its_leading() { // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index 8dd5fe4bac..c53318c358 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -196,6 +196,35 @@ fn insert_aki_places_each_moved_character() { assert!(!out.svg.contains("` where the character is +/// drawn, so a reader draws the text where the canvas does, with fewer ``s asked for too. +/// On a path the type is outlined. +#[test] +fn tsume_places_each_moved_character() { + let tight = CharStyle { tsume: 50.0, ..style(12.0) }; + let runs = + vec![run("H", style(12.0)), run("O", tight.clone()), run("H ", style(12.0)), run("OHO\nHO", CharStyle { tsume: 100.0, ..style(12.0) })]; + for justify in [Justify::Left, Justify::Center, Justify::Right] { + assert_drawn_in_place(&area_doc(runs.clone(), 200.0, justify)); + } + let d = area_doc(runs, 200.0, Justify::Left); + let plain = tspans(&export(&area_doc(vec![run("HOH OHO\nHO", style(12.0))], 200.0, Justify::Left), &ExportOptions::default())).len(); + let fewer = tspans(&export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() })).len(); + assert!(fewer > plain, "each moved character is placed with fewer tspans too: {plain} {fewer}"); + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.runs = vec![run("HO", tight)]; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = vectorcraft_svg::export_full(&d, &ExportOptions::default(), None); + assert!(!out.svg.contains(" { painted: Option, /// It paints with the foreground colour (palette index 0xFFFF). foreground_used: bool, + /// A fill draws nothing (a radial gradient none of whose circles has a positive radius), so a + /// glyph with nothing else is colour art that draws nothing, not its outline. + drew_nothing: bool, } impl<'a> Tree<'a> { @@ -166,6 +169,7 @@ impl<'a> Tree<'a> { too_large: false, painted: None, foreground_used: false, + drew_nothing: false, } } @@ -254,7 +258,7 @@ impl<'a> Tree<'a> { self.close(); } let root = self.groups.pop()?; - if root.children.is_empty() { + if root.children.is_empty() && !self.drew_nothing { return None; } Some(Arc::new(Node::group(NO_ID, root.children))) @@ -308,22 +312,27 @@ impl<'a> Tree<'a> { } Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => { let spread = spread(*extend); - let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1)); + let line = stops(color_stops, self); + // Without a stop, the others would repeat over a different interval. + if !spread.is_pad() && line.len() < color_stops.len() { + self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette"); + return None; + } + let Some((c0, r0, c1, r1, mut stops)) = non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), line, *extend) + else { + self.drew_nothing = true; + return None; + }; let concentric = (c0 - c1).hypot() < 1e-6; if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 { self.unsupported.get_or_insert("a radial gradient from a circle"); return None; } - let mut stops = stops(color_stops, self); if stops.iter().any(|s| !(0.0..=1.0).contains(&s.offset)) { self.unsupported.get_or_insert("a radial gradient with stops past its circles"); return None; } if !spread.is_pad() { - if stops.len() < color_stops.len() { - self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette"); - return None; - } // A run of the stops is the whole way from the center to the circle: one that // starts at a circle (a first stop past the center comes as one) repeats over // less of it. The normalized ends can be a rounding error off 0 and 1. @@ -383,6 +392,87 @@ fn normalized(mut stops: Vec) -> Option<(Vec, f32, f Some((stops, first, if span > 0.0 { last } else { first + 1.0 })) } +/// A radial gradient's circles (`c0`, `r0`) and (`c1`, `r1`) and color line `stops` (0 and 1 at +/// the circles) recast so that neither circle has a negative radius, as normalizing a color line +/// to 0..1 can give. Circles of a negative radius are not drawn, only those on the side of the +/// circle of radius 0 (the tip of the cone) where the radius is positive. With pad, the stops on +/// the other side become one stop at the tip with the color there; with repeat and reflect, the +/// circles move along by whole periods of the color line to the drawn side +/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). `None` +/// when no circle is drawn. +fn non_negative_radii( + c0: Point, + r0: f64, + c1: Point, + r1: f64, + mut stops: Vec, + extend: Extend, +) -> Option<(Point, f64, Point, f64, Vec)> { + if (r0 >= 0.0 && r1 >= 0.0) || stops.is_empty() { + return Some((c0, r0, c1, r1, stops)); + } + if r0 == r1 { + return None; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let tip = r0 / (r0 - r1); + let grows = r1 > r0; + let (from, to, stops) = match extend { + Extend::Repeat | Extend::Reflect => { + // A reflected color line repeats every 2: once forward, then back. + let (period, stops) = if extend == Extend::Reflect { + let forward = stops.iter().map(|s| GradientStop { offset: s.offset / 2.0, ..s.clone() }); + let back = stops.iter().rev().map(|s| GradientStop { offset: 1.0 - s.offset / 2.0, ..s.clone() }); + (2.0, forward.chain(back).collect()) + } else { + (1.0, stops) + }; + let from = if grows { (tip / period).ceil() * period } else { (tip / period).floor() * period - period }; + (from, from + period, stops) + } + _ => { + // The color line runs from 0 to 1, and further where its stops do. + let first = stops.first().map_or(0.0, |s| f64::from(s.offset).min(0.0)); + let last = stops.last().map_or(1.0, |s| f64::from(s.offset).max(1.0)); + let (from, to) = match (grows, tip) { + (true, t) if t < last => (t, last), + (true, t) => (t, t + 1.0), + (false, t) if t > first => (first, t), + (false, t) => (t - 1.0, t), + }; + let line = Gradient { stops, ..Gradient::default() }; + // The color at `w`, as a stop at `offset` of the new line. + let at = |w: f64, offset: f32| { + let (color, opacity) = line.sample(w as f32); + GradientStop { opacity, ..GradientStop::new(offset, color) } + }; + // Stops at the tip stay on the drawn side of its stop, so a sharp change of color there + // keeps the color on that side. + let mut kept: Vec = line + .stops + .iter() + .filter(|s| (from - 1e-6..=to + 1e-6).contains(&f64::from(s.offset))) + .map(|s| GradientStop { offset: ((f64::from(s.offset) - from) / (to - from)).clamp(0.0, 1.0) as f32, ..s.clone() }) + .collect(); + if grows { + kept.insert(0, at(tip, 0.0)); + if kept.last().is_none_or(|s| s.offset < 1.0) { + kept.push(at(to, 1.0)); + } + } else { + kept.push(at(tip, 1.0)); + if kept.first().is_none_or(|s| s.offset > 0.0) { + kept.insert(0, at(from, 0.0)); + } + } + (from, to, kept) + } + }; + let circle = |w: f64| (c0.lerp(c1, w), (r0 + w * (r1 - r0)).max(0.0)); + let ((c0, r0), (c1, r1)) = (circle(from), circle(to)); + Some((c0, r0, c1, r1, stops)) +} + impl ColorPainter for Tree<'_> { fn push_transform(&mut self, transform: Transform) { let t = self.transform() * affine(&transform); @@ -575,10 +665,24 @@ impl ColorPainter for Pixels<'_, '_> { Brush::LinearGradient { p0, p1, color_stops, extend: e } => { peniko::Gradient::new_linear(point(p0), point(p1)).with_stops(stops(color_stops).as_slice()).with_extend(extend(e)).into() } - Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => peniko::Gradient::new_two_point_radial(point(c0), r0, point(c1), r1) - .with_stops(stops(color_stops).as_slice()) - .with_extend(extend(e)) - .into(), + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => { + let line = color_stops + .iter() + .map(|s| { + let (c, a) = colour(&self.palette, self.foreground, s.palette_index, s.alpha).unwrap_or((Color::BLACK, 0.0)); + GradientStop { opacity: a, ..GradientStop::new(s.offset, c) } + }) + .collect(); + let Some((c0, r0, c1, r1, line)) = non_negative_radii(point(c0), f64::from(r0), point(c1), f64::from(r1), line, e) else { return }; + let stops: Vec = line + .iter() + .map(|s| { + let [r, g, b, a] = s.color.to_rgba8(s.opacity); + peniko::ColorStop::from((s.offset, peniko::Color::from_rgba8(r, g, b, a))) + }) + .collect(); + peniko::Gradient::new_two_point_radial(c0, r0 as f32, c1, r1 as f32).with_stops(stops.as_slice()).with_extend(extend(e)).into() + } Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => { peniko::Gradient::new_sweep(point(c0), start_angle.to_radians(), end_angle.to_radians()) .with_stops(stops(color_stops).as_slice()) diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index 4c75aa3dbc..0f8ce4facd 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -56,6 +56,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { kerning_before: _, aki_left: _, aki_right: _, + tsume: _, optical_kerning, baseline_shift, h_scale, diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 671ebee7a1..9e4cb10e36 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -256,6 +256,11 @@ pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_do /// back before bisecting the earlier ones. const MAX_DRAWN_CHECKS: usize = 256; +/// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one when a glyph of +/// the line has a negative advance: a later break can then be drawn narrower than an earlier one, +/// and bisecting can miss the latest break that fits. +const MAX_DRAWN_CHECKS_STEPPING_BACK: usize = 1024; + /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make /// millions of outlines. const MAX_LEADER_COPIES: f64 = 4096.0; @@ -357,8 +362,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// from `i` ending before glyph `end` (with a hyphen when `hyphenated`) fits `width` as it is drawn: /// once the line holds a tab, whose width depends on what is drawn before it, the line reads on /// while the least it can be drawn at fits `width`, then ends at the latest legal break where it -/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, the earlier ones without a -/// hyphen are bisected); hyphenation points are measured the same way. +/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, or +/// [`MAX_DRAWN_CHECKS_STEPPING_BACK`] when a glyph of the line has a negative advance, the earlier +/// ones without a hyphen are bisected); hyphenation points are measured the same way. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -377,24 +383,26 @@ pub(super) fn break_line( fits: Option<&dyn Fn(usize, bool) -> bool>, ) -> (usize, bool) { // The line ending at `chosen`, once it holds a tab, when it fits as drawn; otherwise the latest - // of the legal `breaks` before it that fits as drawn, measuring at most MAX_DRAWN_CHECKS of - // them from the latest back and bisecting the earlier ones without a hyphen; the first of them - // when none of those measured fits. - let settle = |tabbed: bool, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { + // of the legal `breaks` before it that fits as drawn, measuring at most `checks` of them from + // the latest back and bisecting the earlier ones without a hyphen; the first of them when none + // of those measured fits. + let settle = |tabbed: bool, checks: usize, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { let Some(fits) = fits.filter(|_| tabbed) else { return chosen }; if fits(chosen.0, chosen.1) { return chosen; } let earlier = breaks.get(breaks.partition_point(|b| b.0 <= i)..breaks.partition_point(|b| b.0 < chosen.0)).unwrap_or_default(); - if let Some(&b) = earlier.iter().rev().take(MAX_DRAWN_CHECKS).find(|b| fits(b.0, b.1)) { + if let Some(&b) = earlier.iter().rev().take(checks).find(|b| fits(b.0, b.1)) { return b; } // Without a hyphen, a line that ends later is drawn no narrower, so those breaks bisect. - let rest = earlier.get(..earlier.len().saturating_sub(MAX_DRAWN_CHECKS)).unwrap_or_default(); + let rest = earlier.get(..earlier.len().saturating_sub(checks)).unwrap_or_default(); let plain: Vec<&(usize, bool)> = rest.iter().filter(|b| !b.1).collect(); let fitting = plain.partition_point(|b| fits(b.0, b.1)); fitting.checked_sub(1).and_then(|k| plain.get(k).copied()).or(earlier.first()).copied().unwrap_or(chosen) }; + // How many breaks a line measures as drawn one by one. + let checks = |back: f64| if back > 0.0 { MAX_DRAWN_CHECKS_STEPPING_BACK } else { MAX_DRAWN_CHECKS }; let mut breaks: Vec<(usize, bool)> = Vec::new(); let mut tabbed = false; if !width.is_finite() { @@ -462,7 +470,7 @@ pub(super) fn break_line( let cluster_ink = if is_tab { 0.0 } else { cluster_width }; let cluster_back: f64 = if is_tab { 0.0 } else { g[j..cluster_end].iter().map(|glyph| (-glyph.adv).max(0.0)).sum() }; if gl.ch == '\u{2028}' { - return settle(tabbed, (cluster_end, false), &breaks); + return settle(tabbed, checks(back), (cluster_end, false), &breaks); } // Measured as the line is set when it ends with this cluster: only the cluster's last glyph // is narrowed, and only where the line may end. @@ -509,7 +517,7 @@ pub(super) fn break_line( j = cluster_end; } if j >= g.len() { - return settle(tabbed, (g.len(), false), &breaks); + return settle(tabbed, checks(back), (g.len(), false), &breaks); } // Hyphenate the word that overflows. A list marker starting the line is not part of a word. let word_start = last_break.map_or(first, |b| b.0); @@ -534,18 +542,19 @@ pub(super) fn break_line( .filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) .collect(); // The points the line may end at, the latest first. Measured as drawn, at most - // MAX_DRAWN_CHECKS of them are measured one by one, and the earlier ones are bisected: + // `checks(back)` of them are measured one by one, and the earlier ones are bisected: // each ends with a hyphen, so a later one is drawn no narrower. let points: Vec = pts.iter().rev().copied().filter(|&k| k <= j && k > i).collect(); let drawn = fits.filter(|_| tabbed); - for &k in points.iter().take(if drawn.is_some() { MAX_DRAWN_CHECKS } else { usize::MAX }) { + let one_by_one = checks(back); + for &k in points.iter().take(if drawn.is_some() { one_by_one } else { usize::MAX }) { let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; if drawn.map_or(w <= width + EPS, |fits| fits(k, true)) { return (k, true); } } if let Some(fits) = drawn { - let earlier: Vec = points.iter().skip(MAX_DRAWN_CHECKS).rev().copied().collect(); + let earlier: Vec = points.iter().skip(one_by_one).rev().copied().collect(); let fitting = earlier.partition_point(|&k| fits(k, true)); if let Some(&k) = fitting.checked_sub(1).and_then(|n| earlier.get(n)) { return (k, true); @@ -555,7 +564,7 @@ pub(super) fn break_line( // Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only // cluster starts, so the resulting end always keeps each source cluster together. match last_break { - Some(b) if b.0 > i => settle(tabbed, b, &breaks), + Some(b) if b.0 > i => settle(tabbed, checks(back), b, &breaks), _ => (j, false), } } diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 1ee9a3fe50..0d4ec2834e 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -24,6 +24,10 @@ fn pushes_in(para: &ParaStyle) -> bool { /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; +/// The widths tried, halving the difference each time, for the longest break of a line placed again +/// at its own metrics in more room than it was broken in ([`place_flow_line`]). +const REFIT_STEPS: usize = 24; + /// Whether every line of area type in `regions` is as wide however tall it is and wherever it is set: /// rectangular cells without text wrap. fn constant_width(regions: Option<&[Region]>) -> bool { @@ -415,25 +419,31 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let word = word_width(sg, *i, line_end, para.kinsoku); // A composed line in a frame whose lines are not all as wide is placed at its own tallest // metrics, as the composer placed it (`shaped::Shaped`): its room is the room across its height. - let est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + let composed_line = composed.as_ref().and_then(|lines| lines.get(*para_line)).is_some_and(|&(end, _)| end > *i); + let mut est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, _)) if end > *i && !constant_width(pen.regions) => sg.get(*i..end).and_then(Metrics::max).unwrap_or(est), _ => est, }; + // A line broken here in such a frame is placed again when it turns out taller than it was + // placed: its room is the room across its own height. + let refit = !composed_line && pen.regions.is_some() && !constant_width(pen.regions); // Place, break, then settle the baseline on the line's real metrics (moving on to the // next row/column if it no longer fits). loop { + let before = refit.then(|| pen.clone()); let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { + let Some((mut baseline, mut x0, mut x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { cx.out.overflow = cx.text.len() > if *i < n { sg[*i].byte } else { pr.start }; return None; }; - let width = x1 - x0 - ind_l - ind_r; let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((¶.hyphenation, *zone)); - let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + let glyphs: &[SGlyph] = sg; + // The line broken in room `width` wide: its end, whether it ends with a hyphen, and whether + // it pushed characters in. + let break_in = |width: f64| match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { let start = *i; - let glyphs: &[SGlyph] = sg; // A horizontal line with right-to-left text is drawn in another order than its // text's, and its tabs take their stops in the order they are drawn: whether the line // ending before glyph `end` fits is measured as it is drawn ([`drawn_width`]). @@ -478,8 +488,56 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC } _ => (n, false, false), }; - let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); - baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { + let (mut end, mut hyph, mut pushed_in) = break_in(x1 - x0 - ind_l - ind_r); + let metrics = |end: usize| glyphs.get(*i..end).and_then(Metrics::max).unwrap_or(*pm); + let m = metrics(end); + // The baseline moves from where `est` placed it to where the line's metrics set it, unless + // the line was placed at metrics of its own (below). + let mut settle = true; + if let Some(before) = before + && m != est + { + if m.union(est) != est { + // Taller than it was placed: place it again at metrics that hold both, and break it + // again in the room there. + *pen = before; + est = est.union(m); + continue; + } + // Broken again in the room of a taller line, it no longer holds what made that line + // taller. It goes where its own metrics place it when the room there holds the room it + // was broken in, and otherwise stays where it was placed, inside the room measured. + settle = false; + let mut own = before; + if let Some((b, a, z, _)) = own.place(m, ind_l, ind_r, word) + && a <= x0 + EPS + && z >= x1 - EPS + { + // The room there may hold more of the line, up to what would make it taller than + // `m`: the longest break that does not is between the two widths (a line breaks no + // earlier in more room). + let (mut lo, mut hi) = (x1 - x0 - ind_l - ind_r, z - a - ind_l - ind_r); + let wider = break_in(hi); + if metrics(wider.0).union(m) == m { + (end, hyph, pushed_in) = wider; + } else { + for _ in 0..REFIT_STEPS { + let mid = (lo + hi) / 2.0; + let line = break_in(mid); + if metrics(line.0).union(m) == m { + (end, hyph, pushed_in) = line; + lo = mid; + } else { + hi = mid; + } + } + } + *pen = own; + (baseline, x0, x1) = (b, a, z); + } + } + let m = metrics(end); + baseline += if !settle || same_baseline || (pen.regions.is_none() && first_in_region) { 0.0 } else if pen.model == LeadingModel::EmBoxTop { // The em box top is fixed (the frame's top, or the line above's): the baseline @@ -544,6 +602,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 +866,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 +882,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 +965,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 +1085,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 +1125,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 +1156,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 +1188,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 +1315,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..e80d890cef 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)); @@ -327,7 +386,7 @@ fn slant_boundaries(slants: &mut [Slant]) -> (Vec<(f64, f64)>, bool, Vec) } /// The most pieces a line's band is cut into to keep it inside an Invert Wrap shape grown by its -/// offset. +/// offset, when [`grown_band`] gives up, or out of a wrap shape shrunk by its offset. const INVERT_WRAP_PIECES: f64 = 64.0; /// The edges of `polys` that reach into the band `top..bottom` (`top <= bottom`), leaving out those @@ -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,19 +572,307 @@ 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()))); } union_intervals(out) } +/// The most steps [`grown_band`] takes for one band and one wrap shape: edges, pairs of edges and +/// pairs of grown pieces looked at, and pieces looked at for each x it tests. +const MAX_INVERT_WRAP_WORK: usize = 1 << 20; + +#[cfg(test)] +thread_local! { + /// How many steps [`grown_band`] took on this thread. + static GROWN_BAND_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Adds `n` to `work`; false when that passes [`MAX_INVERT_WRAP_WORK`]. +fn spend(work: &mut usize, n: usize) -> bool { + *work = work.saturating_add(n); + #[cfg(test)] + GROWN_BAND_WORK.set(GROWN_BAND_WORK.get().saturating_add(n)); + *work <= MAX_INVERT_WRAP_WORK +} + +/// The x of the edge `p`–`q` (not horizontal) at `y`: the x of its end at that end's height. +fn edge_x(p: Point, q: Point, y: f64) -> f64 { + if y == p.y { + p.x + } else if y == q.y { + q.x + } else { + line_x(p, q, y) + } +} + +/// A trapezoid with a horizontal top and bottom: its top and bottom heights, then the x of its left +/// edge at each of them, then the x of its right edge at each of them. +type Trapezoid = (f64, f64, f64, f64, f64, f64); + +/// The shape `polys` (even-odd) between the heights `y0..y1` (`y0 < y1`) as trapezoids with +/// horizontal tops and bottoms, cut at each anchor's height and wherever two edges cross, whose union +/// (each trapezoid with its outline) is the shape with its outline there. Edges that lie along one +/// another give trapezoids that touch, and contours that cancel each other give none. `None` when +/// [`MAX_INVERT_WRAP_WORK`] runs out. +fn trapezoids(polys: &[Vec], y0: f64, y1: f64, work: &mut usize) -> Option> { + 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.y != b.y && a.y.max(b.y) > y0 && a.y.min(b.y) < y1) + .map(|(a, b)| if a.y < b.y { (a, b) } else { (b, a) }) + .collect(); + if !spend(work, edges.len()) { + return None; + } + edges.sort_by(|u, v| u.0.y.total_cmp(&v.0.y)); + let mut ys: Vec = edges.iter().flat_map(|(p, q)| [p.y, q.y]).filter(|&y| y > y0 && y < y1).chain([y0, y1]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup(); + let mut out = Vec::new(); + let (mut next, mut active) = (0, Vec::new()); + for w in ys.windows(2) { + let (a, b) = (w[0], w[1]); + // The edges that span the slab `a..b`: every anchor's height is a cut, so an edge that + // starts at or above `a` and ends below it reaches `b`. + while let Some(&e) = edges.get(next).filter(|e| e.0.y <= a) { + active.push(e); + next += 1; + } + active.retain(|e: &(Point, Point)| e.1.y >= b); + if !spend(work, active.len()) { + return None; + } + // The heights within the slab at which two edges cross: their order left to right differs + // at its top and bottom. + let mut cuts = vec![a, b]; + let ends: Vec<(f64, f64)> = active.iter().map(|&(p, q)| (edge_x(p, q, a), edge_x(p, q, b))).collect(); + let mut order: Vec = (0..ends.len()).collect(); + order.sort_by(|&i, &j| ends[i].0.total_cmp(&ends[j].0).then(ends[i].1.total_cmp(&ends[j].1))); + if order.windows(2).any(|w| ends[w[0]].1 > ends[w[1]].1) { + if !spend(work, ends.len() * ends.len()) { + return None; + } + for (i, &(sa, sb)) in ends.iter().enumerate() { + for &(ta, tb) in &ends[i + 1..] { + let (da, db) = (sa - ta, sb - tb); + if da * db < 0.0 { + let y = a + (b - a) * (da / (da - db)); + if y > a && y < b { + cuts.push(y); + } + } + } + } + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + } + for c in cuts.windows(2) { + let (c, d) = (c[0], c[1]); + // No two edges cross within `c..d`, so their order at its middle holds throughout, and + // the shape (even-odd) lies between the first and second, the third and fourth, and so on. + let mid = (c + d) * 0.5; + let mut xs: Vec<(f64, f64, f64)> = active.iter().map(|&(p, q)| (line_x(p, q, mid), edge_x(p, q, c), edge_x(p, q, d))).collect(); + if !spend(work, xs.len()) { + return None; + } + xs.sort_by(|u, v| u.0.total_cmp(&v.0)); + for pair in xs.as_chunks::<2>().0 { + let ((_, l0, l1), (_, r0, r1)) = (pair[0], pair[1]); + if r0 > l0 || r1 > l1 { + out.push((c, d, l0, l1, r0, r1)); + } + } + } + } + Some(out) +} + +/// A convex polygon as its lower and upper outlines (the least and the greatest y at each x), each +/// left to right from its least x to its greatest, with its x range and y range. +struct Convex { + lower: Vec, + upper: Vec, + xs: (f64, f64), + ys: (f64, f64), +} + +impl Convex { + /// The convex hull of `pts`. + fn hull(mut pts: Vec) -> Convex { + let cross = |o: Point, a: Point, b: Point| (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + let chain = |pts: &[Point], turns: fn(f64) -> bool| { + let mut out: Vec = Vec::with_capacity(pts.len()); + for &p in pts { + while let [.., o, a] = out[..] { + if turns(cross(o, a, p)) { + break; + } + out.pop(); + } + out.push(p); + } + // The points at the greatest x after the first are on the other outline. + while let [.., a, b] = out[..] { + if a.x != b.x { + break; + } + out.pop(); + } + out + }; + pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(p.y.total_cmp(&q.y))); + let lower = chain(&pts, |c| c > 0.0); + pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(q.y.total_cmp(&p.y))); + let upper = chain(&pts, |c| c < 0.0); + let xs = (lower.first().map_or(f64::NAN, |p| p.x), lower.last().map_or(f64::NAN, |p| p.x)); + let ys = (lower.iter().fold(f64::INFINITY, |m, p| m.min(p.y)), upper.iter().fold(f64::NEG_INFINITY, |m, p| m.max(p.y))); + Convex { lower, upper, xs, ys } + } + + /// The y range of the polygon at `x`, within its x range. + fn at(&self, x: f64) -> (f64, f64) { + (chain_y(&self.lower, x), chain_y(&self.upper, x)) + } +} + +/// The y of the outline `chain` (left to right, no two points at one x) at `x`, within its x range. +fn chain_y(chain: &[Point], x: f64) -> f64 { + let i = chain.partition_point(|p| p.x < x); + match (i.checked_sub(1).and_then(|h| chain.get(h)), chain.get(i)) { + (Some(p), Some(q)) => p.y + (x - p.x) / (q.x - p.x) * (q.y - p.y), + (None, Some(q)) => q.y, + (Some(p), None) => p.y, + (None, None) => f64::NAN, + } +} + +/// The x at which some segment of `a` and some segment of `b` (outlines left to right) are at the +/// same y, strictly between the ends of the x range they share. +fn chain_crossings(a: &[Point], b: &[Point], out: &mut Vec, work: &mut usize) -> bool { + for s in a.windows(2) { + let first = b.partition_point(|q| q.x <= s[0].x).saturating_sub(1); + for t in b[first..].windows(2).take_while(|t| t[0].x < s[1].x) { + if !spend(work, 1) { + return false; + } + let (x0, x1) = (s[0].x.max(t[0].x), s[1].x.min(t[1].x)); + if x1 <= x0 { + continue; + } + let (d0, d1) = (chain_y(s, x0) - chain_y(t, x0), chain_y(s, x1) - chain_y(t, x1)); + if d0 * d1 < 0.0 { + out.push(x0 + (x1 - x0) * (d0 / (d0 - d1))); + } + } + } + true +} + +/// The x within `lo..hi` at which the shape `polys` (even-odd) grown by `off` (> 0) across and +/// along holds every height of the band `top..bottom` (`top <= bottom`): the x whose vertical line +/// across the band has each of its points within `off`, across and along, of the shape or its +/// outline. The shape from `off` above the band to `off` below it, and on to the nearest anchors +/// past those heights, is cut into [`trapezoids`]; each +/// grown by `off` is a convex polygon, and the line at an x lies in their union when the y ranges +/// they give at that x join up across the band. Those ranges change only at the polygons' corners +/// and where their outlines cross each other or the band's top or bottom, so the line is tested +/// once between each two of those x. `None` when that takes more than [`MAX_INVERT_WRAP_WORK`] +/// steps. +fn grown_band(polys: &[Vec], off: f64, top: f64, bottom: f64, (lo, hi): (f64, f64)) -> Option> { + let mut work = 0; + // The trapezoids reach on to the nearest anchor past `off` above and below the band, so that + // the shape's outline exactly `off` from the band counts. + let (y0, y1) = (top - off, bottom + off); + let ys = || polys.iter().flatten().map(|p| p.y); + let below = ys().filter(|&y| y < y0).fold(f64::NEG_INFINITY, f64::max); + let above = ys().filter(|&y| y > y1).fold(f64::INFINITY, f64::min); + let traps = trapezoids(polys, if below.is_finite() { below } else { y0 }, if above.is_finite() { above } else { y1 }, &mut work)?; + let mut grown: Vec = traps + .into_iter() + .filter(|&(_, _, l0, l1, r0, r1)| l0.min(l1) - off < hi && r0.max(r1) + off > lo) + .map(|(y0, y1, l0, l1, r0, r1)| { + let corners = [(l0, y0), (r0, y0), (l1, y1), (r1, y1)]; + let pts = corners + .into_iter() + .flat_map(|(x, y)| [(-off, -off), (off, -off), (-off, off), (off, off)].map(|(dx, dy)| Point::new(x + dx, y + dy))); + Convex::hull(pts.collect()) + }) + .collect(); + grown.sort_by(|p, q| p.xs.0.total_cmp(&q.xs.0)); + // The x at which the line's place among the polygons can change. + let mut xs = vec![lo, hi]; + for (j, p) in grown.iter().enumerate() { + xs.extend(p.lower.iter().chain(&p.upper).map(|v| v.x)); + for chain in [&p.lower, &p.upper] { + for s in chain.windows(2) { + for y in [top, bottom] { + let (d0, d1) = (s[0].y - y, s[1].y - y); + if d0 * d1 < 0.0 { + xs.push(s[0].x + (s[1].x - s[0].x) * (d0 / (d0 - d1))); + } + } + } + } + let ((_, x1), (y0, y1)) = (p.xs, p.ys); + for q in grown[j + 1..].iter().take_while(|q| q.xs.0 < x1) { + if !spend(&mut work, 1) { + return None; + } + let (v0, v1) = q.ys; + if v0 <= y1 + && y0 <= v1 + && !(chain_crossings(&p.upper, &q.lower, &mut xs, &mut work) && chain_crossings(&q.upper, &p.lower, &mut xs, &mut work)) + { + return None; + } + } + } + xs.retain(|&x| x >= lo && x <= hi); + xs.sort_by(f64::total_cmp); + xs.dedup(); + // Test the line between each two of those x, walking left to right with the polygons whose x + // range holds it. + let mut out: Vec<(f64, f64)> = Vec::new(); + let (mut next, mut active, mut ranges): (usize, Vec<&Convex>, Vec<(f64, f64)>) = (0, Vec::new(), Vec::new()); + for w in xs.windows(2) { + let x = (w[0] + w[1]) * 0.5; + while let Some(p) = grown.get(next).filter(|p| p.xs.0 <= x) { + active.push(p); + next += 1; + } + active.retain(|p| p.xs.1 >= x); + if !spend(&mut work, active.len()) { + return None; + } + ranges.clear(); + ranges.extend(active.iter().map(|p| p.at(x))); + ranges.sort_by(|u, v| u.0.total_cmp(&v.0)); + // The line is held from the band's top to `reach`, up to the first range that starts past + // it; a band of no height is held when a range reaches it. + let (mut reach, mut met) = (top, false); + for &(a, b) in &ranges { + if a > reach { + break; + } + reach = reach.max(b); + met |= b >= top; + } + if met && reach >= bottom { + match out.last_mut() { + Some(l) if l.1 >= w[0] => l.1 = w[1], + _ => out.push((w[0], w[1])), + } + } + } + Some(out) +} + impl Region { pub(super) fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec { // A frame out of range has no cells, and a wrap shape out of range is left out. @@ -595,10 +945,20 @@ impl Region { } else { subtract_intervals(&iv, &pad(eroded_cover(polys, top, bottom, -off))) } + } else if let Some(held) = (*invert && *off > 0.0) + .then(|| iv.first().zip(iv.last()).and_then(|(f, l)| grown_band(polys, *off, top, bottom, (f.0 - inset, l.1 + inset)))) + .flatten() + { + // The x at which the shape grown by the offset holds the whole band. + intersect_intervals(&iv, &shrink(held)) } else if *invert { - // The band is cut into pieces. Each height of a piece `y0..y1` is within the offset - // of some height from `y0` plus the offset to `y1` less the offset, so an x the - // shape holds at all of those heights is inside the grown shape over the piece. In + // With no offset, or when measuring the grown shape exactly takes more than + // [`MAX_INVERT_WRAP_WORK`] steps, the band is cut into pieces, which can leave a line + // shorter than the grown shape allows, never longer. + // + // Each height of a piece `y0..y1` is within the offset of some height from `y0` + // plus the offset to `y1` less the offset, so an x the shape holds at all of those + // heights is inside the grown shape over the piece. In // a piece less than twice the offset tall, each height of the piece is within the // offset of every height from `y1` less the offset to `y0` plus the offset, so an x // the shape holds at any of them is. Text within the offset of such an x, for every @@ -1090,6 +1450,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)] @@ -1166,8 +1556,8 @@ fn intersecting_intervals_looks_at_each_interval_once() { } /// A line in area type with an Invert Wrap comb of 500 teeth and an offset keeps a span inside -/// each tooth, left to right, through every piece of its band, and intersecting the spans with each -/// piece looks at each span once. +/// each tooth, left to right, and measuring the grown comb takes a few dozen steps for each tooth, +/// not one for each pair of teeth. #[cfg(test)] #[test] fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { @@ -1179,11 +1569,92 @@ fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 0.25, invert: true } }; let cells = Region::cells(&frame, &LayoutOptions::default(), &[wrap]); INTERSECT_PAIRS.set(0); + GROWN_BAND_WORK.set(0); let got = cells[0].spans(40.0, 60.0); let want: Vec<(f64, f64)> = (0..500).map(|k| f64::from(k) * 2.0).map(|x| ((x - 0.25).max(0.0), x + 1.25)).collect(); assert_spans(&got, &want); - // At most 64 pieces, each looking at no more than the spans so far and the tooth spans. - assert!(INTERSECT_PAIRS.get() <= 64 * (500 + 500), "{} pairs", INTERSECT_PAIRS.get()); + // The line's one span is intersected once with the 500 spans the grown comb holds. + assert!(INTERSECT_PAIRS.get() <= 1 + 500, "{} pairs", INTERSECT_PAIRS.get()); + assert!(GROWN_BAND_WORK.get() <= 40 * 500, "{} steps", GROWN_BAND_WORK.get()); +} + +/// The spans of a 120 pt wide, 100 pt tall frame over the band `top..bottom` with one Invert Wrap +/// object through `contour` and `offset`. +#[cfg(test)] +fn invert_wrap_spans(contour: &[(f64, f64)], offset: f64, top: f64, bottom: f64) -> Vec<(f64, f64)> { + let frame = Rect::new(0.0, 0.0, 120.0, 100.0).to_path(0.1); + let path = PathData::from_bezpath(&closed(&[contour])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset, invert: true } }; + Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(top, bottom) +} + +/// The 100 pt square whose right edge steps in to x = 80 from `y0` to `y1`, with 79 more anchors +/// along its left edge, which leave its shape as it is. +#[cfg(test)] +fn notched_square(y0: f64, y1: f64) -> Vec<(f64, f64)> { + let mut contour = vec![(0.0, 0.0), (100.0, 0.0), (100.0, y0), (80.0, y0), (80.0, y1), (100.0, y1), (100.0, 100.0), (0.0, 100.0)]; + contour.extend((1..80).rev().map(|i| (0.0, 1.25 * f64::from(i)))); + contour +} + +/// With Invert Wrap and an offset, a line beside a notch the offset fills reaches as far as the +/// grown shape does at every height of its band, however tall the band and however many anchors +/// the shape has: a notch 0.5625 pt tall with an offset of 1, and one 0.4 pt tall with an offset +/// of 0.25 over a band of 100 pt. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_lets_a_line_beside_a_notch_reach_the_grown_shape() { + assert_spans(&invert_wrap_spans(¬ched_square(50.5, 51.0625), 1.0, 0.0, 100.0), &[(0.0, 101.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.6, 51.0), 0.25, 0.0, 100.0), &[(0.0, 100.25)]); + // A band of no height in the notch, and one in a notch the offset does not fill. + assert_spans(&invert_wrap_spans(¬ched_square(50.5, 51.0625), 1.0, 50.7, 50.7), &[(0.0, 101.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.0, 53.0), 1.0, 0.0, 100.0), &[(0.0, 81.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.0, 53.0), 1.0, 51.5, 51.5), &[(0.0, 81.0)]); +} + +/// With Invert Wrap and an offset, a line beside a notch with sloped sides reaches as far as the +/// grown shape does at the notch's tip: 1 pt above and below the tip, each side is 8 pt from it. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_lets_a_line_beside_a_sloped_notch_reach_the_grown_shape() { + let notch = [(0.0, 0.0), (100.0, 0.0), (100.0, 45.15), (60.0, 50.15), (100.0, 55.15), (100.0, 100.0), (0.0, 100.0)]; + assert_spans(&invert_wrap_spans(¬ch, 1.0, 40.0, 60.0), &[(0.0, 69.0)]); + assert_spans(&invert_wrap_spans(¬ch, 5.0, 40.0, 60.0), &[(0.0, 105.0)]); +} + +/// With Invert Wrap and an offset, a band of no height exactly the offset past a wrap shape's +/// bottom edge or its tip still holds a line as wide as the grown edge or tip. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_holds_a_band_exactly_the_offset_past_the_shape() { + let bar = [(20.0, 0.0), (60.0, 0.0), (60.0, 10.0), (20.0, 10.0)]; + assert_spans(&invert_wrap_spans(&bar, 1.0, 11.0, 11.0), &[(19.0, 61.0)]); + assert!(invert_wrap_spans(&bar, 1.0, 11.5, 11.5).is_empty()); + let tip = [(20.0, 0.0), (80.0, 0.0), (50.0, 10.0)]; + assert_spans(&invert_wrap_spans(&tip, 1.0, 11.0, 11.0), &[(49.0, 51.0)]); + let tip = [(20.0, 20.0), (50.0, 10.0), (80.0, 20.0)]; + assert_spans(&invert_wrap_spans(&tip, 1.0, 9.0, 9.0), &[(49.0, 51.0)]); +} + +/// A wrap shape whose edges cross one another tens of thousands of times takes no more than +/// [`MAX_INVERT_WRAP_WORK`] steps to measure exactly; it is then cut into pieces, and the line +/// still stays inside the shape grown by the offset. An offset that is not a number is 0. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_bounds_its_work_on_a_tangled_shape() { + let star: Vec = (0..301) + .map(|k| f64::from(k * 150) * std::f64::consts::TAU / 301.0) + .map(|a| Point::new(50.0 + 45.0 * a.cos(), 50.0 + 45.0 * a.sin())) + .collect(); + GROWN_BAND_WORK.set(0); + assert!(grown_band(std::slice::from_ref(&star), 1.0, 10.0, 90.0, (0.0, 120.0)).is_none()); + assert!(GROWN_BAND_WORK.get() <= 2 * MAX_INVERT_WRAP_WORK, "{} steps", GROWN_BAND_WORK.get()); + let contour: Vec<(f64, f64)> = star.iter().map(|p| (p.x, p.y)).collect(); + let spans = invert_wrap_spans(&contour, 1.0, 10.0, 90.0); + assert!(spans.iter().all(|&(a, b)| a >= 4.0 && b <= 96.0), "{spans:?}"); + let square = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]; + assert_spans(&invert_wrap_spans(&square, f64::NAN, 45.0, 55.0), &invert_wrap_spans(&square, 0.0, 45.0, 55.0)); + assert_spans(&invert_wrap_spans(&square, 0.0, 45.0, 55.0), &[(40.0, 60.0)]); } /// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap, diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index eae22bab8a..1878ce962d 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -646,6 +646,55 @@ fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() { assert_eq!(ended, (5, false)); } +/// A long tabbed line with a glyph that steps back ends at the latest break that fits as drawn, even +/// where a later break than one that does not fit fits again. +#[test] +fn a_long_tabbed_line_that_steps_back_ends_at_the_latest_break_that_fits_as_drawn() { + // a (0), tab (1), then b and a space for each u: breaks before 4 + 2u. + let text = format!("a\t{}", "b ".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -0.5 } else { 1.0 }, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end == 200; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 1e6, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (200, false)); +} + +/// A tabbed line with a glyph that steps back, ending in a word with more hyphenation points than +/// are measured one by one, ends at the latest point that fits as drawn. +#[test] +fn a_tabbed_line_that_steps_back_ends_at_the_latest_hyphenation_point_that_fits_as_drawn() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + // a (0), tab (1), then a word of 400 syllables of two glyphs from glyph 2: points before 4, 6, ... + let text = format!("a\t{}", "कि".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -1.0 } else { 1.0 }, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + for glyph in &mut glyphs { + glyph.hyphen_language = crate::hyphen::Language::from_tag(Some("hi")); + glyph.sx = 0.001; + } + let drawn_fits = |end: usize, _hyphenated: bool| end <= 20 || end == 50; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = break_line( + &text, + &glyphs, + 0, + 600.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + fits, + ); + assert_eq!(ended, (50, true)); +} + /// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed /// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last /// glyph of the last cluster counts, and only where the line may end. @@ -1173,11 +1222,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/shape.rs b/crates/text/src/shape.rs index ff79ce9a4c..befcbed41f 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -13,7 +13,7 @@ use kurbo::Shape; use skrifa::MetadataProvider; use skrifa::charmap::Charmap; use skrifa::instance::Size; -use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI}; +use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI, MAX_TSUME}; use vectorcraft_doc::{CharStyle, Digits, InlineArt}; use crate::features::{OtFeatures, ligatures_suppressed_by}; @@ -969,9 +969,9 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Can one glyph draw characters in styles `a` and `b`? OpenType features already apply to their /// own character ranges during shaping, so feature-only differences do not split a cluster. /// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets. -/// The space before a line's first character ([`space_before`]) and Insert Aki ([`aki`]) come -/// from the cluster's first character, so they do not split a ligature or a letter from its marks -/// either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]). +/// The space before a line's first character ([`space_before`]), Insert Aki ([`aki`]) and Tsume +/// ([`tighten`]) come from the cluster's first character, so they do not split a ligature or a +/// letter from its marks either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]). fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { // Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists. let CharStyle { @@ -986,6 +986,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { kerning_before, aki_left, aki_right, + tsume, optical_kerning, baseline_shift, h_scale, @@ -1034,6 +1035,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { kerning_before == &a.kerning_before && aki_left == &a.aki_left && aki_right == &a.aki_right + && tsume == &a.tsume && offsets == other_offsets && font_family == &b.font_family && font_style == &b.font_style @@ -1094,7 +1096,7 @@ pub(crate) fn warichu_alike(a: &CharStyle, b: &CharStyle) -> bool { /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its /// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]), -/// Insert Aki ([`aki`]), the OpenType features (each run's apply to its own characters, as features +/// Insert Aki ([`aki`]), Tsume ([`tighten`]), the OpenType features (each run's apply to its own characters, as features /// over a range of the one buffer) and the tracking, as long as the tracking keeps or drops /// standard ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { @@ -1111,6 +1113,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { kerning_before: _, aki_left: _, aki_right: _, + tsume: _, optical_kerning: _, baseline_shift: _, h_scale, @@ -1332,6 +1335,9 @@ struct Shaped { /// from their letters and the character-level pass matches ([`crate::harakat`]); empty /// otherwise (glyphs keep their shaping-cluster start). chars: Vec, + /// Each glyph's x advance without the font's pair kerning, the width Tsume measures, when + /// horizontal type with Tsume sets the same glyphs without it; empty otherwise. + unkerned: Vec, } /// The features that turn the whole-text features `base` into `own` over the clusters `range`, put @@ -1443,6 +1449,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } ranged }; + // Horizontal type with Tsume on any of the segment's characters. + let tsume_here = !feats.vertical && runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| s.tsume > 0.0); // Shape the segment as if all of it were in `st`, with the features `more` on top. let shape_in = |st: &CharStyle, more: &[Feature]| { // A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one. @@ -1452,7 +1460,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) .and_then(|l| l.parse::().ok()); let mut shaped = Shaped::default(); - let Shaped { raw, proportional, vpal, chars } = &mut shaped; + let Shaped { raw, proportional, vpal, chars, unkerned } = &mut shaped; let hb_shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // The baseline and character-level pass use exactly the same resolved features. @@ -1531,6 +1539,16 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe *proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect(); } } + if tsume_here { + // Tsume measures each glyph on its own width: the same glyphs set without the + // font's pair kerning (`kern` and `apkn`) give it. + let mut bare = along_features.clone(); + bare.extend([b"kern", b"apkn"].map(|t| Feature::new(Tag::new(t), 0, range.start..=range.end))); + let bare = shape(&bare, along); + if same_glyphs(&bare) { + *unkerned = bare.iter().map(|g| g.2).collect(); + } + } if feats.proportional_vertical(st) { // `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set // top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`) @@ -1577,7 +1595,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe shaped }; let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) }; - let Shaped { raw, proportional, vpal, chars } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { + let Shaped { raw, proportional, vpal, chars, unkerned } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { // The pair entering Optical keeps the left character's font kerning. Subsequent // Optical pairs use outline spacing, so font pair features stop at its second character. let optical_off = if first == range.start { @@ -1711,11 +1729,16 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; let ch = first_char(cl); let mut adv = xa as f64 * k * hs; + // The glyph's own width, without the font's pair kerning and before the tracking, manual + // kerning and Insert Aki after it: what Tsume measures. + let mut own = unkerned.get(gi).map_or(adv, |&u| f64::from(u) * k * hs); // The chosen glyph takes its own advance. let chosen = chosen_at(gi); let gid = match chosen { Some(c) => { - adv += (face.advance(c.gid) - face.advance(gid)) * k * hs; + let wider = (face.advance(c.gid) - face.advance(gid)) * k * hs; + adv += wider; + own += wider; c.gid } None => gid, @@ -1723,7 +1746,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let mut kern_shift = 0.0; let mut tracked = 0.0; let (mut aki_shift, mut aki_in_adv) = (0.0, 0.0); - if ch == SOFT_HYPHEN || ch == '\u{2028}' { + let invisible = ch == SOFT_HYPHEN || ch == '\u{2028}'; + if invisible { adv = 0.0; } else { let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); @@ -1785,9 +1809,61 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cursive, hyphen_language: crate::hyphen::Language::from_tag(gst.language.as_deref()), }); + // Tsume takes its share of the blank space beside the cluster's ink once the cluster is + // complete, in horizontal type. + if last_in_cluster && !invisible && !feats.vertical && gst.tsume > 0.0 { + let first = out.iter().rposition(|g| g.byte != cl).map_or(0, |i| i + 1); + tighten(out.get_mut(first..).unwrap_or_default(), own, gst.tsume); + } } } +/// Tsume ([`CharStyle::tsume`]) on one cluster: `glyphs` are its glyphs in the order they are +/// drawn along the line, the last one with `own` as its width before the tracking, kerning and +/// Insert Aki its advance holds. `tsume` percent of the blank space between the cluster's own width +/// and its ink, on each side, comes out of the cluster's advance, and its glyphs are drawn the +/// left side's share earlier. A cluster without ink, such as a space, or with a width that is not +/// positive, keeps its place and width. +fn tighten(glyphs: &mut [SGlyph], own: f64, tsume: f64) { + let Some(last) = glyphs.len().checked_sub(1) else { return }; + let share = if tsume.is_nan() { 0.0 } else { tsume.clamp(0.0, MAX_TSUME) / 100.0 }; + let (mut pen, mut ink): (f64, Option<(f64, f64)>) = (0.0, None); + for (i, g) in glyphs.iter().enumerate() { + if let Some((a, b)) = ink_across(&g.face, g.gid) { + let (a, b) = (pen + g.dx + a * g.sx, pen + g.dx + b * g.sx); + let (lo, hi) = (a.min(b), a.max(b)); + ink = Some(ink.map_or((lo, hi), |(l, h)| (l.min(lo), h.max(hi)))); + } + pen += if i == last { own } else { g.adv }; + } + let Some((lo, hi)) = ink.filter(|(lo, hi)| lo.is_finite() && hi.is_finite()) else { return }; + if !(pen.is_finite() && pen > 0.0) { + return; + } + let left = lo.clamp(0.0, pen) * share; + let right = (pen - hi).clamp(0.0, pen) * share; + for g in glyphs.iter_mut() { + g.dx -= left; + } + if let Some(g) = glyphs.get_mut(last) { + g.adv -= left + right; + } +} + +/// The left and right edges of glyph `gid`'s outline in `face`, in font units; `None` for a glyph +/// without an outline (a space, or a bitmap or color glyph without one). +fn ink_across(face: &FontFace, gid: u32) -> Option<(f64, f64)> { + let font = face.skrifa()?; + let glyph = font.outline_glyphs().get(skrifa::GlyphId::new(gid))?; + let mut pen = crate::fontdb::FlipPen(kurbo::BezPath::new()); + glyph.draw(skrifa::outline::DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen).ok()?; + if pen.0.elements().is_empty() { + return None; + } + let b = pen.0.bounding_box(); + Some((b.x0, b.x1)) +} + #[cfg(test)] mod optical_tests { use super::*; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index bdb818d3f3..7abae3f0eb 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1939,6 +1939,12 @@ pub fn colr_variable_font() -> Option> { with_colr(&variable_font()?, family_names(COLR_VARIABLE_FAMILY, &axis_names)?, &colr) } +/// [`variable_font`] (one weight axis, 400 to 700) as `family` with `colr` and [`colr_font`]'s +/// palette. `None` when it can't be built. +pub fn colr_variable_font_with(family: &str, colr: &write_fonts::tables::colr::Colr) -> Option> { + with_colr(&variable_font()?, family_names(family, &[])?, colr) +} + /// A `name` table for Regular of `family`, with `more` records. fn family_names(family: &str, more: &[(u16, &str)]) -> Option> { let ps = family.replace(' ', ""); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e7e4f33ff6..423984a630 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -30,6 +30,7 @@ mod shape_plans; mod snap; mod svg_glyphs; mod sysfonts; +mod tsume; mod typo; mod variable; mod versions; diff --git a/crates/text/src/tests/colr.rs b/crates/text/src/tests/colr.rs index c3a0499af6..c6c4696f16 100644 --- a/crates/text/src/tests/colr.rs +++ b/crates/text/src/tests/colr.rs @@ -25,7 +25,7 @@ use vectorcraft_doc::color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{Node, NodeKind}; use crate::colr::{ColourContent, colour_glyph}; -use crate::test_fonts::{COLR_GREEN, COLR_RED}; +use crate::test_fonts::{COLR_BLUE, COLR_GREEN, COLR_RED}; const FOREGROUND: Color = Color::Rgb { r: 0.25, g: 0.5, b: 0.75 }; @@ -358,6 +358,147 @@ fn a_radial_gradient_from_a_circle_around_its_centre_is_vector_art() { assert_eq!(offsets, [0.25, 1.0], "the stops start a quarter of the way out"); } +/// A padded radial gradient whose color line starts before 0 starts at the circle of radius 0: +/// circles of a negative radius are not drawn, so the stops before that circle become one stop +/// there with the color between them +/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). With its +/// centers apart, the gradient is focused on the tip of its cone; with its centers together, it +/// is centered there with no stop below 0. +#[test] +fn a_radial_gradient_with_a_stop_before_0_starts_at_its_circle_of_radius_0() { + use vectorcraft_doc::color::{Gradient, GradientStop}; + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let half = F2Dot14::from_f32(0.5); + let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, half), ColorStop::new(one, 1, one)]); + let w = FWord::new; + // Half-opaque red at -0.5 and blue at 1: at 0, a third of the way from red to blue, in color + // and opacity. + let red = GradientStop { opacity: 0.5, ..GradientStop::new(0.0, rgb(COLR_RED)) }; + let tip = Gradient { stops: vec![red, GradientStop::new(1.0, rgb(COLR_BLUE))], ..Gradient::default() }.sample(1.0 / 3.0); + assert!((tip.1 - 2.0 / 3.0).abs() < 1e-6, "{tip:?}"); + for (family, c1_x, focal) in [("Colrtest Tip", 250, Some(300.0)), ("Colrtest Dot", 300, None)] { + let font = v1_font(family, COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(c1_x), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("{family}: a gradient") }; + let offsets: Vec = g.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 1.0], "{family}"); + let first = &g.gradient.stops[0]; + assert_eq!((first.color, first.opacity), tip, "{family}: the color at the circle of radius 0"); + assert_eq!(g.gradient.stops[1].color, rgb(COLR_BLUE), "{family}"); + let geom = g.geom.unwrap(); + assert!((geom.start.x - f64::from(c1_x)).abs() < 1e-3 && (geom.start.y + 300.0).abs() < 1e-3, "{family}: {geom:?}"); + assert!(((geom.end - geom.start).hypot() - 400.0).abs() < 1e-3, "{family}: {geom:?}"); + assert_eq!(geom.focal.map(|f| ((f.x * 1e3).round() / 1e3, (f.y * 1e3).round() / 1e3)), focal.map(|x| (x, -300.0)), "{family}"); + } +} + +/// A sharp change of color at the circle of radius 0 of a gradient whose color line starts +/// before 0 keeps the color beyond it: red up to 0, then green fading to blue. +#[test] +fn a_sharp_change_of_color_at_the_circle_of_radius_0_keeps_the_color_beyond_it() { + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let (one, zero) = (F2Dot14::from_f32(1.0), F2Dot14::from_f32(0.0)); + let stops = vec![ + ColorStop::new(F2Dot14::from_f32(-1.0), 0, one), + ColorStop::new(zero, 0, one), + ColorStop::new(zero, 2, one), + ColorStop::new(one, 1, one), + ]; + let line = ColorLine::new(Extend::Pad, 4, stops); + let w = FWord::new; + let font = v1_font("Colrtest Edge", COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(250), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("a gradient") }; + let stops = &g.gradient.stops; + let colors: Vec = stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [rgb(COLR_RED), rgb(COLR_RED), rgb(COLR_GREEN), rgb(COLR_BLUE)], "{stops:?}"); + assert!(stops[..3].iter().all(|s| s.offset.abs() < 1e-5) && stops[3].offset == 1.0, "{stops:?}"); +} + +/// A glyph drawn into an image draws a radial gradient with a circle of a negative radius from the +/// circle of radius 0, as vector art does: with pad, the color at the tip of the cone and the +/// colors on its drawn side, not one solid color. A composite that keeps only its backdrop makes +/// the glyph an image. Red runs to blue; the circles are around the `O`'s middle and larger than it. +#[test] +fn an_image_of_a_radial_gradient_with_a_stop_past_its_circle_of_radius_0_draws_from_that_circle() { + use write_fonts::tables::colr::{ColorLine, ColorStop, CompositeMode, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let w = FWord::new; + type Check = fn([u8; 3]) -> bool; + // Red at -0.5 (a start circle of radius -250 once normalized): the colors run from a third of + // the way to blue at the tip to blue. Blue at 1.5 (an end circle of radius -250): they run + // from red at the outer circle, which lies outside the `O`, to two thirds of the way to blue + // at the tip. Either way every opaque pixel holds some blue. + let cases: [(&str, f32, f32, u16, u16, Check); 2] = + [("Colrtest Tip Image", -0.5, 1.0, 0, 500, |[r, _, b]| b > 20 && r < 240), ("Colrtest Rim Image", 0.0, 1.5, 500, 0, |[_, _, b]| b > 20)]; + for (family, first, last, r0, r1, check) in cases { + let line = + ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(first), 0, one), ColorStop::new(F2Dot14::from_f32(last), 1, one)]); + let font = v1_font(family, COLOUR_CHAR, |o| { + let radial = ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(r0), w(290), w(330), UfWord::new(r1)); + ColrPaint::composite(ColrPaint::glyph(solid(2), o), CompositeMode::Dest, ColrPaint::glyph(radial, o)) + }); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") }; + let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap(); + let pixels: Vec<[u8; 3]> = pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect(); + assert!(pixels.len() > 100, "{family}: the `O` is drawn"); + let wrong: Vec<_> = pixels.iter().filter(|p| !check(**p)).take(5).collect(); + assert!(wrong.is_empty(), "{family}: {wrong:?}"); + } + // Repeat and reflect draw their bands, not one color. + for (family, extend) in [("Colrtest Repeat Image", Extend::Repeat), ("Colrtest Reflect Image", Extend::Reflect)] { + let line = ColorLine::new(extend, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, one), ColorStop::new(one, 1, one)]); + let font = v1_font(family, COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(0), w(290), w(330), UfWord::new(100)), o) + }); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") }; + let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap(); + let colors: std::collections::HashSet<[u8; 3]> = + pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect(); + assert!(colors.len() > 10, "{family}: {colors:?}"); + } +} + +/// A radial gradient whose circles both have the same negative radius at a variable font's +/// instance draws no circle, so a glyph painted with only that gradient is colour art that draws +/// nothing, not its outline. The radii are 10 at the default weight and -10 at 700. +#[test] +fn a_radial_gradient_with_no_circle_drawn_leaves_the_glyph_empty() { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Extend, VarColorLine, VarColorStop}; + use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList}; + use write_fonts::types::{F2Dot14, FWord, GlyphId16, UfWord}; + let bytes = colr_font(false).unwrap(); + let read = skrifa::FontRef::new(&bytes).unwrap(); + let gid = |ch| GlyphId16::new(u16::try_from(read.charmap().map(ch).unwrap().to_u32()).unwrap()); + let (one, fixed) = (F2Dot14::from_f32(1.0), 0xFFFF_FFFF); + let line = + VarColorLine::new(Extend::Pad, 2, vec![VarColorStop::new(F2Dot14::from_f32(0.0), 0, one, fixed), VarColorStop::new(one, 1, one, fixed)]); + let w = FWord::new; + let radial = ColrPaint::var_radial_gradient(line, w(290), w(330), UfWord::new(10), w(290), w(330), UfWord::new(10), 0); + let mut colr = Colr::new(0, None, None, 0); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), ColrPaint::glyph(radial, gid('O')))])).into(); + // One 16-bit delta for each of x0, y0, r0, x1, y1 and r1, at full weight. + let deltas: Vec = [0i16, 0, -20, 0, 0, -20].iter().flat_map(|d| d.to_be_bytes()).collect(); + let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), one, one)]); + let data = ItemVariationData::new(6, 1, vec![0], deltas); + colr.item_variation_store = Some(ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)])).into(); + let font = crate::test_fonts::colr_variable_font_with("Colrtest No Circle", &colr).unwrap(); + let font = skrifa::FontRef::new(&font).unwrap(); + let bold = font.axes().location([("wght", 700.0)]); + let Some(ColourContent::Art(root)) = colour_glyph(&font, (&bold).into(), 1000.0, u32::from(gid(COLOUR_CHAR).to_u16()), FOREGROUND).0 else { + panic!("colour art") + }; + assert!(children(&root).is_empty(), "{:?}", children(&root).iter().map(|c| c.kind_label()).collect::>()); +} + /// A COLRv1 color line through `stops` (offset, palette index) that extends by `extend`. fn color_line(extend: write_fonts::tables::colr::Extend, stops: &[(f32, u16)]) -> write_fonts::tables::colr::ColorLine { use write_fonts::tables::colr::{ColorLine, ColorStop}; diff --git a/crates/text/src/tests/combos.rs b/crates/text/src/tests/combos.rs index 4f57aa6972..cdb5a31fee 100644 --- a/crates/text/src/tests/combos.rs +++ b/crates/text/src/tests/combos.rs @@ -378,8 +378,8 @@ fn x_width() -> f64 { fn composer_x_mixed_sizes_in_a_stepped_frame() { // A frame as wide as the first line down to 40 pt and half as wide below. A 36 pt word makes // the line it is on reach below the step, where the frame is narrow: the every-line composer - // breaks the paragraph with each line's measure where its height sets it, not as the - // single-line composer does, and every line keeps within its room. + // breaks the paragraph with each line's measure where its height sets it, and every line keeps + // within its room. let w = x_width(); let set = |x: CharStyle, composer: Composer| { let mut t = stepped(w, w * 0.5, 40.0, 300.0); @@ -394,8 +394,10 @@ fn composer_x_mixed_sizes_in_a_stepped_frame() { // The 36 pt line reaches below the step and is set at the narrow measure. let tall = &l.lines[1]; assert!(tall.ascent > 30.0 && tall.baseline + tall.descent > 40.0 && tall.avail.1 < w * 0.5 + 0.01, "{:?}", l.lines); + // The single-line composer measures each line across its height too, and breaks this + // paragraph the same way. let (greedy, _) = set(sans(36.0), Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); // Uniform sizes keep the breaks they had. let (uniform, _) = set(sans(12.0), Composer::EveryLine); assert_eq!(uniform, ["Destroy all creatures with toughness X or", "less. Then draw a card for each creature", "destroyed this way."]); @@ -435,8 +437,10 @@ fn composer_x_mixed_sizes_around_text_wrap() { across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0 }); assert_eq!(hits.count(), 0, "{:?}", l.lines); + // The single-line composer measures each line across its height too, and breaks this + // paragraph the same way. let (greedy, _, _) = set(Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); } #[test] @@ -463,13 +467,42 @@ fn composer_x_mixed_sizes_in_a_frame_that_widens() { assert_eq!(greedy, ["Destroy all", "creatures", "with toughness X or less. Then draw", "a card for each creature destroyed this", "way."]); } +#[test] +fn single_line_composer_measures_a_line_across_its_tallest_glyph() { + // A frame wide enough for "Destroy all creatures X" down to 20 pt and as wide as "Destroy all" + // below, and a rectangle as wide with an object (6 pt wrap offset) beside that part. The first + // line starts with 12 pt words and takes in a 36 pt word, which sets it lower and reaches below + // 20 pt: the single-line composer measures its room across that height, every line that + // reaches the narrow part is set in it, and the first line keeps the 12 pt words that fit + // above it. + let w = natural_width("Destroy all creatures X", 12.0) + 30.0; + let narrow = natural_width("Destroy all", 12.0) + 2.0; + let runs = vec![run("Destroy all creatures ", sans(12.0)), run("X", sans(36.0)), run(" or less. Then draw a card.", sans(12.0))]; + let object = Rect::new(narrow + 6.0, 26.0, w + 10.0, 300.0); + let mut beside = area(runs.clone(), Rect::new(0.0, 0.0, w, 300.0)); + beside.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }]; + let mut shaped = stepped(w, narrow, 20.0, 300.0); + shaped.runs = runs; + for (name, mut t) in [("stepped frame", shaped), ("text wrap", beside)] { + t.para.composer = Composer::SingleLine; + let l = layout(db(), &t); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{name}: {:?}", l.lines); + let low = l.lines.iter().filter(|li| li.baseline + li.descent > 20.01); + assert!(low.clone().all(|li| li.avail.1 <= narrow + 0.01), "{name}: {:?}", l.lines); + // The 36 pt word is set, on a line in the narrow part. + assert!(low.clone().any(|li| li.ascent > 30.0), "{name}: {:?}", l.lines); + assert_eq!(line_texts(&t, &l)[0], "Destroy all creatures", "{name}: {:?}", l.lines); + assert!(l.lines[0].baseline < 20.0 && l.lines[0].avail.1 > w - 0.01, "{name}: {:?}", l.lines); + } +} + #[test] fn composer_x_mixed_sizes_overflow_a_shaped_frame() { // A stepped frame too short for the paragraph: the every-line composer still breaks the lines // that fit as one paragraph, with the lines past the frame's end measured as wide as the last // line the frame holds. Here that is the narrow part, so the lines that fit break as in a frame - // tall enough for the whole paragraph, and not as the single-line composer breaks them. They - // are set in order, each once and within its room, and the frame overflows. + // tall enough for the whole paragraph. They are set in order, each once and within its room, + // and the frame overflows. let w = x_width(); let set = |bottom: f64, composer: Composer| { let mut t = stepped(w, w * 0.5, 40.0, bottom); @@ -487,8 +520,10 @@ fn composer_x_mixed_sizes_overflow_a_shaped_frame() { assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]); let (whole, ..) = set(300.0, Composer::EveryLine); assert_eq!(every, whole[..every.len()]); + // The single-line composer measures each line across its height too, and breaks the lines + // that fit the same way. let (greedy, ..) = set(75.0, Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); // A frame with no room for a line sets none. for composer in [Composer::EveryLine, Composer::SingleLine] { let mut t = stepped(w, w * 0.5, 4.0, 8.0); @@ -529,8 +564,10 @@ fn composer_x_mixed_sizes_overflow_around_text_wrap() { assert_eq!(hits.count(), 0, "{:?}", l.lines); // The lines that fit break as in `composer_x_mixed_sizes_around_text_wrap`'s taller frame. assert_eq!(every, ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature"]); + // The single-line composer measures each line across its height too, and breaks the lines + // that fit the same way. let (greedy, ..) = set(Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); } #[test] diff --git a/crates/text/src/tests/tsume.rs b/crates/text/src/tests/tsume.rs new file mode 100644 index 0000000000..72f0d69f19 --- /dev/null +++ b/crates/text/src/tests/tsume.rs @@ -0,0 +1,143 @@ +//! Tsume (`CharStyle::tsume`): the share of the blank space on each side of a character's ink, +//! inside its own width, that horizontal type takes away, the glyph keeping its shape. +use super::{area, db, point, style}; +use crate::*; +use kurbo::Shape; +use vectorcraft_doc::{CharStyle, Justify}; + +/// Where glyph `byte` is drawn along a horizontal line. +fn drawn_x(l: &TextLayout, byte: usize) -> f64 { + l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap() +} + +/// The left and right edges of glyph `byte`'s ink. +fn ink(l: &TextLayout, byte: usize) -> (f64, f64) { + let b = l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.outline.bounding_box()).unwrap(); + (b.x0, b.x1) +} + +/// `t` with `f` applied to the style of the character at `byte`. +fn with(t: &TextObject, byte: usize, f: impl Fn(&mut CharStyle)) -> TextObject { + let mut t = t.clone(); + let end = edit::next_char(&t.plain_text(), byte); + edit::style_range(&mut t.runs, byte, end, f); + t +} + +/// How far each of `bytes` moved along the line from `a` to `b`. +fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec { + let (la, lb) = (layout(db(), a), layout(db(), b)); + bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect() +} + +fn near(values: &[f64], expected: &[f64]) -> bool { + values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6) +} + +/// The blank space before and after the O of "HOH" in `t`'s layout: between the start of its +/// width and its ink, and between its ink and the next character. +fn o_bearings(t: &TextObject) -> (f64, f64) { + let l = layout(db(), t); + let (x0, x1) = ink(&l, 1); + (x0 - drawn_x(&l, 1), drawn_x(&l, 2) - x1) +} + +#[test] +fn tsume_takes_its_share_of_the_space_beside_the_ink() { + let plain = point("HOH", style(100.0)); + let (before, after) = o_bearings(&plain); + assert!(before > 1.0 && after > 1.0, "{before} {after}"); + let plain_layout = layout(db(), &plain); + let width = ink(&plain_layout, 1).1 - ink(&plain_layout, 1).0; + for tsume in [25.0, 50.0, 100.0] { + let tight = with(&plain, 1, |s| s.tsume = tsume); + let share = tsume / 100.0; + // The O's ink moves back by its share of the space before it; the H after it also moves + // back by the share of the space after the O. + let l = layout(db(), &tight); + let (x0, x1) = ink(&l, 1); + let moved = (x0 - ink(&plain_layout, 1).0, ink(&l, 2).0 - ink(&plain_layout, 2).0); + assert!((moved.0 + before * share).abs() < 1e-6, "{tsume}: {moved:?}"); + assert!((moved.1 + (before + after) * share).abs() < 1e-6, "{tsume}: {moved:?}"); + // The glyph keeps its shape, and the H before it stays. + assert!((x1 - x0 - width).abs() < 1e-6, "{tsume}: {} {width}", x1 - x0); + assert!((ink(&l, 0).0 - ink(&plain_layout, 0).0).abs() < 1e-9); + } + // At 100% the O's ink starts where the H before it ends its width, and the next H starts its + // width where the O's ink ends. + let l = layout(db(), &with(&plain, 1, |s| s.tsume = 100.0)); + let cell_start = drawn_x(&plain_layout, 1); + assert!((ink(&l, 1).0 - cell_start).abs() < 1e-6 && (drawn_x(&l, 2) - ink(&l, 1).1).abs() < 1e-6, "{:?} {cell_start}", ink(&l, 1)); +} + +#[test] +fn zero_out_of_range_and_spaces_keep_the_layout() { + let plain = point("H O", style(100.0)); + let base = layout(db(), &plain); + for (tsume, at) in [(0.0, 2), (f64::NAN, 2), (-50.0, 2), (100.0, 1)] { + // 0, a value that is not a number, a negative value, and a space at 100%. + let l = layout(db(), &with(&plain, at, |s| s.tsume = tsume)); + let xs: Vec = l.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect(); + let base_xs: Vec = base.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect(); + assert_eq!(xs, base_xs, "{tsume} at {at}"); + } + // A value above 100 takes all the space, as 100 does. + let most = moves(&plain, &with(&plain, 2, |s| s.tsume = 500.0), &[2]); + let full = moves(&plain, &with(&plain, 2, |s| s.tsume = 100.0), &[2]); + assert!(near(&most, &full) && full[0] < -1.0, "{most:?} {full:?}"); +} + +#[test] +fn tracking_and_insert_aki_are_added_after_the_tsume() { + let plain = point("HOH", style(100.0)); + let tight = moves(&plain, &with(&plain, 1, |s| s.tsume = 50.0), &[0, 1, 2]); + let spaced = moves(&plain, &with(&plain, 1, |s| (s.tsume, s.aki_left, s.tracking) = (50.0, 250.0, 100.0)), &[0, 1, 2]); + // 25 pt of Aki before the O, and 10 pt of tracking after it, on top of the Tsume. + assert!(tight[1] < -1.0 && tight[2] < tight[1], "{tight:?}"); + assert!(near(&spaced, &[0.0, tight[1] + 25.0, tight[2] + 35.0]), "{tight:?} {spaced:?}"); +} + +#[test] +fn the_font_kerning_is_added_after_the_tsume() { + // An L, with blank space after its ink, before a letter the font kerns it with: its Tsume is + // the same as before an H, which the font does not kern it with. + let lh = point("LH", style(100.0)); + let kerned = ["LT", "LV", "LY", "LW"] + .map(|s| point(s, style(100.0))) + .into_iter() + .find(|t| drawn_x(&layout(db(), t), 1) < drawn_x(&layout(db(), &lh), 1) - 1.0); + let Some(kerned) = kerned else { panic!("the test font kerns none of the pairs") }; + let before_kerned = moves(&kerned, &with(&kerned, 0, |s| s.tsume = 100.0), &[1]); + let before_h = moves(&lh, &with(&lh, 0, |s| s.tsume = 100.0), &[1]); + assert!(near(&before_kerned, &before_h) && before_h[0] < -10.0, "{before_kerned:?} {before_h:?}"); +} + +#[test] +fn a_letter_with_its_mark_takes_the_tsume_of_the_letter() { + let plain = point("H\u{301}O", style(100.0)); + let after = "H\u{301}".len(); + let letter = moves(&plain, &with(&plain, 0, |s| s.tsume = 100.0), &[after]); + let mark = moves(&plain, &with(&plain, 1, |s| s.tsume = 100.0), &[after]); + assert!(letter[0] < -1.0 && near(&mark, &[0.0]), "{letter:?} {mark:?}"); +} + +#[test] +fn justification_keeps_the_tsume() { + let plain = area("HOH HOH", style(20.0), Rect::new(0.0, 0.0, 400.0, 400.0), Justify::JustifyAll); + let tight = with(&plain, 1, |s| s.tsume = 100.0); + let (lp, lt) = (layout(db(), &plain), layout(db(), &tight)); + // The O stays nearer the H before it by the space Tsume took before its ink. + let gap = |l: &TextLayout| ink(l, 1).0 - ink(l, 0).1; + assert!(gap(<) < gap(&lp) - 1.0, "{} {}", gap(&lp), gap(<)); +} + +#[test] +fn vertical_type_keeps_its_spacing() { + for text in ["§§", "ab"] { + let mut plain = point(text, style(20.0)); + plain.xf = Affine::IDENTITY; + plain.vertical = true; + let centers = |t: &TextObject| layout(db(), t).glyphs.iter().map(|g| g.outline.bounding_box().center()).collect::>(); + assert_eq!(centers(&with(&plain, 0, |s| s.tsume = 100.0)), centers(&plain), "{text}"); + } +} diff --git a/crates/text/src/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/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index adbf3b2c70..4ea7696aae 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -415,8 +415,7 @@ fn stepped_frame(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject { /// A paragraph of mixed type sizes that a stepped frame cannot hold goes on in the next frame. The /// every-line composer breaks the lines the first frame holds as one paragraph, as in a frame tall -/// enough for all of it and not as the single-line composer breaks them, and the first frame -/// takes the text of those lines. The next frame sets the rest at its own width, without the +/// enough for all of it, and the first frame takes the text of those lines. The next frame sets the rest at its own width, without the /// first-line indent, and no character is lost or repeated. The frames take the same shares as /// when each is measured with the whole rest of the story. #[test] @@ -460,8 +459,10 @@ fn a_mixed_size_paragraph_continued_from_a_shaped_frame_is_composed_in_both() { assert_eq!(every, whole[..every.len()], "indent {indent}"); if indent == 0.0 { assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]); + // The single-line composer measures each line across its height too, and breaks the + // first frame's lines the same way. let (greedy, ..) = shares(Composer::SingleLine, indent); - assert_eq!(greedy, ["Destroy all creatures with toughness X", "or less. Then draw a", "card for each", "creature destroyed"]); + assert_eq!(greedy, every); } // The next frame starts at its left edge, and its lines keep within it. assert!((line_start(&lays[1], 0) - 200.0).abs() < 1e-6, "{}", line_start(&lays[1], 0)); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 7a83c811a1..bb066c0e8a 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -371,6 +371,10 @@ pub struct Face { /// For a face of the swept side (not an end cap): where its corners lie on the side surface, /// for a graphic mapped onto it. pub surface: Option, + /// The face's plane in view space and the projection it was drawn with, for its depth at a + /// point of the drawing ([`DepthPlane::depth_at`]). `None` when its corners lie more than + /// 0.001 pt off one plane or a corner is at or past the eye. + pub plane: Option, } /// Where a face of Revolve's swept side lies on the side surface, unrolled flat: x runs around the @@ -638,6 +642,14 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi normal = V3(-normal.0, -normal.1, -normal.2); } let flat_brightness = brightness(&o, normal, light); + // As for an extruded face: no plane when a corner lies off it, or when the projection holds a + // corner just in front of the eye, so the drawing no longer matches the plane's projection. + let centre = vertices.iter().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); + let centre = V3(centre.0 / vertices.len() as f64, centre.1 / vertices.len() as f64, centre.2 / vertices.len() as f64); + let offset = normal.dot(centre); + let held = eye.is_some_and(|e| vertices.iter().any(|v| e - v.2 < e * 1e-3)); + let flat = !held && vertices.iter().all(|v| (normal.dot(*v) - offset).abs() <= 1e-3); + let plane = flat.then_some(DepthPlane { normal: [normal.0, normal.1, normal.2], offset, center: origin, eye }); let points: Vec<_> = vertices .iter() .map(|v| { @@ -707,6 +719,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi cap: false, visibility_pieces: None, surface: None, + plane, })) } diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs index 66842abad2..2d32850423 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -172,8 +172,8 @@ impl ShadowCaster { /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each /// shape whose entry in `casts` is true: the eye is placed for every shape, as for the solids - /// drawn, but the faces of the others are not kept (their range is empty). A shape that does - /// not inflate is left out, as its solid is; the result says which, in order. + /// drawn, but the solids of the others are not built (their range is empty). A casting shape + /// that does not inflate is left out, as its solid is; the result says which, in order. pub fn inflate( &mut self, shapes: &[(&PathData, FillRule)], @@ -182,11 +182,15 @@ impl ShadowCaster { options: Inflate, view: View, ) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, Keep::Faces) - .enumerate() - .map(|(i, r)| { - let (_, faces, projection) = r?; - if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) } + let mut solids = inflate_faces(shapes, bounds, options, view, Keep::Faces); + (0..shapes.len()) + .map(|i| { + if !casts.get(i).copied().unwrap_or(true) { + solids.pass(); + return Ok(self.faces.len()..self.faces.len()); + } + let (_, faces, projection) = solids.next().unwrap_or_else(|| Ok((vec![], Ok(vec![]), None)))?; + self.add(faces?, projection) }) .collect() } diff --git a/crates/three-d/src/shadow/tests.rs b/crates/three-d/src/shadow/tests.rs index 3fb3767177..00aedc3a53 100644 --- a/crates/three-d/src/shadow/tests.rs +++ b/crates/three-d/src/shadow/tests.rs @@ -185,3 +185,35 @@ fn every_effect_casts_a_shadow() { // The surface turns about the square's left edge, reaching 100 to either side of it. assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}"); } + +/// An Inflate shape that casts nothing still places the eye with its puff, but its solid is not +/// built (nor, with no volume, its puff solved); the shapes that cast keep the very faces and +/// projection they have when every shape casts. +#[test] +fn an_inflate_shape_that_casts_nothing_builds_no_solid() { + let (a, b) = (square(100.0, 100.0, 100.0), square(150.0, 150.0, 30.0)); + let shapes = [(&a, FillRule::NonZero), (&b, FillRule::NonZero)]; + let bounds = Rect::new(100.0, 100.0, 200.0, 200.0); + let view = View { perspective: 60.0, rotation_y: 30.0, ..front() }; + let built = || crate::solid::INFLATED.with(std::cell::Cell::get); + let puffed = || crate::solid::PUFFED.with(std::cell::Cell::get); + for o in [Inflate::default(), Inflate { volume: 0.0, depth: 20.0, ..Inflate::default() }] { + let mut all = ShadowCaster::new(); + let every = all.inflate(&shapes, &[true, true], bounds, o, view); + let (start, solved) = (built(), puffed()); + let mut some = ShadowCaster::new(); + let kept = some.inflate(&shapes, &[false, true], bounds, o, view); + assert_eq!(built(), start + 1, "{o:?}: only the casting shape's solid is built"); + if o.volume == 0.0 { + assert_eq!(puffed(), solved + 1, "{o:?}: only the casting shape's puff is solved"); + } + let (Ok(first), Ok(second)) = (&every[0], &every[1]) else { panic!("{every:?}") }; + assert!(!first.is_empty() && !second.is_empty()); + assert_eq!(kept, vec![Ok(0..0), Ok(0..second.len())]); + assert_eq!(format!("{:?}", some.faces), format!("{:?}", &all.faces[second.clone()])); + assert_eq!(some.projection, all.projection); + let mut none = ShadowCaster::new(); + assert_eq!(none.inflate(&shapes, &[false, false], bounds, o, view), vec![Ok(0..0), Ok(0..0)]); + assert!(none.is_empty() && none.projection.is_none()); + } +} diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index f67f6c96e8..6badd8048a 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -29,9 +29,9 @@ pub use build::{extrude, plane}; pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] use inflate::GRID_WORK; -#[cfg(test)] -pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; +#[cfg(test)] +pub(crate) use inflate::{INFLATED, PUFFED, SOLVE_FIRST}; pub use inflate::{inflate, inflate_each, inflate_together}; #[cfg(test)] use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; @@ -295,8 +295,8 @@ pub struct Facet { /// (its front). `None` for every other facet. pub end: Option, /// The face's plane in view space and the projection it was drawn with, for its depth at a - /// point of the drawing ([`Facet::depth_at`]). `None` for Revolve's faces and for a face whose - /// corners lie more than 0.001 pt off one plane (a piece of a twisted wall). + /// point of the drawing ([`Facet::depth_at`]). `None` for a face whose corners lie more than + /// 0.001 pt off one plane (a piece of a twisted wall) or reach the eye. pub plane: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] @@ -367,7 +367,7 @@ impl Facet { PathData::from_bezpath(&bp) } - /// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting. + /// Revolve's face `face` as a facet: its polygon, curved outline, depth, plane, normal and lighting. /// A face that joins the profile's ends round the axis is a side; a cap is an end. pub(crate) fn of_face(face: crate::Face) -> Self { Facet { @@ -382,7 +382,7 @@ impl Facet { // A revolved cap is not one of the planar faces graphics map onto. end: None, away: false, - plane: None, + plane: face.plane, #[cfg(test)] view: vec![], } diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 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/inflate.rs b/crates/three-d/src/solid/inflate.rs index 677e9cc8bc..5edf57a894 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -21,12 +21,35 @@ struct Puff { height: f64, } -/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. -fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { +/// The pieces each curve of `path` is sampled in for Inflate's solid ([`curve_pieces`]); an error +/// when its outline does not fit the solid's limits. +fn outline_pieces(path: &PathData, bounds: Rect, o: &Inflate) -> Result { // The sides take one face for each edge of the outline in each slice; the grid's faces are // counted as they are built. + curve_pieces(path, bounds.width().max(bounds.height()), if o.depth > 0.0 { slices(o) } else { 0 }, 0) +} + +/// Whether `path` has few enough anchors that [`curve_pieces`] might take it: fewer than +/// [`MAX_SOLID_POINTS`] contours, and at most three times as many anchors (a contour of one anchor +/// adds one, and every other takes at most two anchors for each segment it adds). Counts the +/// contours' anchors without reading them. +pub(super) fn might_fit(path: &PathData) -> bool { + let mut anchors = 0usize; + for (contours, sp) in path.subpaths.iter().enumerate() { + anchors = anchors.saturating_add(sp.anchors.len()); + if contours >= MAX_SOLID_POINTS || anchors > MAX_SOLID_POINTS.saturating_mul(3) { + return false; + } + } + true +} + +/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. +fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { + #[cfg(test)] + PUFFED.with(|n| n.set(n.get() + 1)); let size = bounds.width().max(bounds.height()); - let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?; + let most = outline_pieces(path, bounds, o)?; let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; if rings.is_empty() { return Ok(None); @@ -66,7 +89,7 @@ pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: } /// [`inflate_together`], each solid built only as the iterator reaches it: a caller that stops early -/// builds none of the solids after. +/// builds none of the solids after (and with no volume, solves none of their puffs). pub fn inflate_each( shapes: &[(&PathData, FillRule)], bounds: Rect, @@ -77,28 +100,98 @@ pub fn inflate_each( } /// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as -/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff); -/// each solid is built as the iterator reaches it. -pub(crate) fn inflate_faces( - shapes: &[(&PathData, FillRule)], - bounds: Rect, - options: Inflate, - view: View, - keep: Keep, -) -> impl Iterator> + use<> { +/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff), +/// except with no volume: nothing puffs then, and each shape's membrane is solved when its solid +/// is taken. Each solid is built as the iterator reaches it. +pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, keep: Keep) -> Solids { let o = options.sanitized(); + let later = o.volume == 0.0; + #[cfg(test)] + let later = later && !SOLVE_FIRST.with(std::cell::Cell::get); + if later { + // A shape is copied to be solved later only when its anchors are few enough for the + // solid's point limit; a larger one is refused now, with the error its puff gives. + let shapes: Vec> = shapes + .iter() + .map(|(path, rule)| { + check_bounds(bounds)?; + if !might_fit(path) { + outline_pieces(path, bounds, &o)?; + } + Ok(((*path).clone(), *rule)) + }) + .collect(); + return Solids { pending: Pending::Later(shapes.into_iter()), bounds, o, view, reach: 0.0, keep }; + } let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); - puffs.into_iter().map(move |p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep), - Ok(None) => Ok((vec![], Ok(vec![]), None)), - Err(e) => Err(e), - }) + Solids { pending: Pending::Solved(puffs.into_iter()), bounds, o, view, reach, keep } +} + +/// The shapes' puffs still to take: every one solved when the shapes were laid out, or each +/// shape's path and fill rule (or the error its bounds or outline already gives), its puff solved +/// when its solid is taken. +enum Pending { + Solved(std::vec::IntoIter, String>>), + Later(std::vec::IntoIter>), +} + +/// The solids of [`inflate_faces`], one result for each shape in order, each built as it is taken. +pub(crate) struct Solids { + pending: Pending, + bounds: Rect, + o: Inflate, + view: View, + /// The highest puff of the shapes drawn together. + reach: f64, + keep: Keep, +} + +impl Solids { + /// Pass over the next shape without building its solid, or solving its puff if that waits. + pub(crate) fn pass(&mut self) { + match &mut self.pending { + Pending::Solved(puffs) => { + puffs.next(); + } + Pending::Later(shapes) => { + shapes.next(); + } + } + } +} + +impl Iterator for Solids { + type Item = Result; + + fn next(&mut self) -> Option { + let p = match &mut self.pending { + Pending::Solved(puffs) => puffs.next()?, + Pending::Later(shapes) => shapes.next()?.and_then(|(path, rule)| puff(&path, rule, self.bounds, &self.o)), + }; + Some(match p { + Ok(Some(p)) => inflated(p, self.bounds, self.o, self.view, self.reach, self.keep), + Ok(None) => Ok((vec![], Ok(vec![]), None)), + Err(e) => Err(e), + }) + } + + fn size_hint(&self) -> (usize, Option) { + match &self.pending { + Pending::Solved(puffs) => puffs.size_hint(), + Pending::Later(shapes) => shapes.size_hint(), + } + } } #[cfg(test)] thread_local! { + /// How many puffs this thread has solved, for the tests. + pub(crate) static PUFFED: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Solve every shape's puff before the first solid is taken even with no volume, as with + /// volume, for the tests that compare the two. + pub(crate) static SOLVE_FIRST: std::cell::Cell = const { std::cell::Cell::new(false) }; /// How many inflated solids this thread has built, for the tests. pub(crate) static INFLATED: std::cell::Cell = const { std::cell::Cell::new(0) }; /// The grid work of the last solid this thread inflated in full, for the tests. 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..a75821e696 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)); @@ -1030,6 +1102,105 @@ fn a_flat_twisted_inflate_matches_the_extrusion() { } } +/// With no volume nothing puffs, so each shape's membrane is solved only when its solid is taken: +/// a caller that stops early (past the object's face limit) solves none after. With volume, the +/// eye waits on the highest puff, so every membrane is solved first. +#[test] +fn a_flat_inflate_solves_each_puff_as_its_solid_is_taken() { + let shapes: Vec = (0..3).map(|i| shape(&square(120.0 * i as f64, 0.0, 100.0)).0).collect(); + let shapes: Vec<(&PathData, FillRule)> = shapes.iter().map(|p| (p, FillRule::NonZero)).collect(); + let b = Rect::new(0.0, 0.0, 340.0, 100.0); + let puffed = || PUFFED.with(std::cell::Cell::get); + let start = puffed(); + let mut each = inflate_each(&shapes, b, Inflate { depth: 20.0, volume: 0.0, ..Inflate::default() }, front()); + assert_eq!(puffed(), start, "no puff is solved before the first solid is taken"); + assert!(each.next().is_some_and(|f| f.is_ok_and(|f| !f.is_empty()))); + assert_eq!(puffed(), start + 1, "the later puffs wait until their solids are taken"); + drop(each); + assert_eq!(puffed(), start + 1); + let start = puffed(); + let each = inflate_each(&shapes, b, Inflate { depth: 20.0, ..Inflate::default() }, front()); + assert_eq!(puffed(), start + 3, "with volume, every puff places the eye"); + assert_eq!(each.count(), 3); +} + +/// With no volume, solving each puff as its solid is taken gives the very solids that solving +/// every puff first gives: their facets, their faces before projection and their projection, bit +/// for bit, with an empty shape and one that cannot be inflated among them. +#[test] +fn a_flat_inflate_solved_as_taken_matches_one_solved_first() { + let mut frame = square(-10.0, -10.0, 260.0); + frame.extend(square(-8.0, -8.0, 256.0)); + let mut circle = BezPath::new(); + circle.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((180.0, 60.0), 50.0), 0.01)); + let mut bad = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true); + bad.anchors[1].h_out = Point::new(f64::NAN, 0.0); + let paths = [shape(&square(0.0, 0.0, 100.0)).0, shape(&frame).0, PathData::new(vec![]), PathData::new(vec![bad]), shape(&circle).0]; + let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect(); + let b = Rect::new(-10.0, -10.0, 250.0, 250.0); + let turned = View { rotation_x: -20.0, rotation_y: 30.0, ..front() }; + let options = [ + Inflate { volume: 0.0, ..Inflate::default() }, + Inflate { volume: 0.0, depth: 40.0, twist: 90.0, taper: 0.5, ..Inflate::default() }, + Inflate { volume: 0.0, depth: 10.0, both_sides: true, cap: false, ..Inflate::default() }, + ]; + for view in [front(), turned, View { perspective: 60.0, ..turned }, View { perspective: 100.0, rotation_y: 150.0, ..turned }] { + for o in options { + for (keep, facets, mesh) in [(build::Keep::Facets, true, false), (build::Keep::Faces, false, true), (build::Keep::Both, true, true)] { + let solids = |first: bool| { + SOLVE_FIRST.with(|s| s.set(first)); + let out: Vec> = inflate_faces(&shapes, b, o, view, keep).collect(); + SOLVE_FIRST.with(|s| s.set(false)); + out + }; + let (taken, first) = (solids(false), solids(true)); + assert_eq!(taken.len(), 5); + // The solid keeps its facets, its faces before projection, or both, as asked. + let built = |r: &Result| { + r.as_ref().is_ok_and(|(f, m, _)| (!facets || !f.is_empty()) && (!mesh || m.as_ref().is_ok_and(|m| !m.is_empty()))) + }; + assert!(built(&taken[0]) && built(&taken[1]) && built(&taken[4]), "{o:?} {view:?} facets {facets} mesh {mesh}"); + assert!( + taken[2].as_ref().is_ok_and(|(f, m, p)| f.is_empty() && m.as_ref().is_ok_and(Vec::is_empty) && p.is_none()) && taken[3].is_err() + ); + assert_eq!(format!("{taken:?}"), format!("{first:?}"), "{o:?} {view:?} facets {facets} mesh {mesh}"); + } + } + } +} + +/// With no volume, a shape with too many anchors or contours for the solid is refused before it +/// is kept for later, with the error that solving every puff first gives it. +#[test] +fn a_flat_inflate_refuses_a_shape_past_the_point_limit_as_one_solved_first() { + use super::inflate::might_fit; + let many = MAX_SOLID_POINTS * 3 + 10; + let anchors: Vec = (0..many).map(|i| Point::new((i % 200) as f64, (i / 200) as f64 / 10.0)).collect(); + let long = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&anchors, true)]); + let mut nan = anchors.clone(); + if let Some(last) = nan.last_mut() { + *last = Point::new(f64::NAN, 0.0); + } + let invalid = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&nan, true)]); + let dots = PathData::new((0..=MAX_SOLID_POINTS).map(|i| vectorcraft_geom::SubPath::polyline(&[Point::new(i as f64, 0.0)], false)).collect()); + let square = shape(&square(0.0, 0.0, 100.0)).0; + assert!(might_fit(&square) && !might_fit(&long) && !might_fit(&invalid) && !might_fit(&dots)); + let paths = [square, long, invalid, dots]; + let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect(); + let b = Rect::new(-10.0, -10.0, 250.0, 250.0); + let o = Inflate { volume: 0.0, ..Inflate::default() }; + let solids = |first: bool| { + SOLVE_FIRST.with(|s| s.set(first)); + let out: Vec> = inflate_faces(&shapes, b, o, front(), build::Keep::Facets).collect(); + SOLVE_FIRST.with(|s| s.set(false)); + out + }; + let (taken, first) = (solids(false), solids(true)); + assert!(taken[0].as_ref().is_ok_and(|(f, ..)| !f.is_empty())); + assert!(taken[1..].iter().all(Result::is_err), "{:?}", taken[1..].iter().map(|r| r.as_ref().err()).collect::>()); + assert_eq!(format!("{taken:?}"), format!("{first:?}")); +} + /// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole /// (the middle is filled, as the art is drawn); one running the other way does, and so does /// either under the even-odd rule. diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index fe42769ebb..5dba29c480 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -375,3 +375,35 @@ fn malformed_and_degenerate_inputs_are_bounded() { assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); } + +/// A revolved face carries its plane: the depth it gives at each corner of its drawing is the +/// corner's view-space depth, with and without perspective. A face whose corners lie off one plane, +/// or with a corner at or past the eye, has none. +#[test] +fn a_revolved_face_measures_its_depth_on_its_plane() { + let o = Revolve { rotation_x: -30.0, rotation_y: 25.0, ..Default::default() }.sanitized(); + let origin = Point::new(50.0, 80.0); + let light = V3(0.0, 0.0, 1.0); + let [a, b, c, d] = [V3(-20.0, -10.0, 5.0), V3(20.0, -12.0, -15.0), V3(25.0, 15.0, -20.0), V3(-18.0, 14.0, 4.0)]; + // The fourth corner moved onto the plane of the first three. + let n = b.sub(a).cross(c.sub(a)); + let d = V3(d.0, d.1, a.2 - (n.0 * (d.0 - a.0) + n.1 * (d.1 - a.1)) / n.2); + let flat = [a, b, c, d]; + for eye in [None, Some(400.0)] { + let face = project_face(&flat, None, o, origin, eye, light).unwrap().unwrap(); + let plane = face.plane.unwrap(); + for (p, v) in face.points.iter().zip(&flat) { + let depth = plane.depth_at(*p).unwrap(); + assert!((depth - v.2).abs() < 1e-6, "{eye:?}: {depth} against {}", v.2); + } + let twisted = [a, b, c, V3(d.0, d.1, d.2 + 1.0)]; + assert!(project_face(&twisted, None, o, origin, eye, light).unwrap().unwrap().plane.is_none(), "{eye:?}"); + } + let past = [a, b, V3(c.0, c.1, 500.0)]; + assert!(project_face(&past, None, o, origin, Some(400.0), light).unwrap().unwrap().plane.is_none()); + assert!(project_face(&past, None, o, origin, Some(1000.0), light).unwrap().unwrap().plane.is_some()); + // Every face of a revolved profile of straight edges has one. + let (p, b) = cylinder(); + let faces = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 35.0, ..Default::default() }).unwrap(); + assert!(!faces.is_empty() && faces.iter().all(|f| f.plane.is_some())); +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index 424242828b..3ed491ae10 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -303,6 +303,7 @@ mod tests { cap: false, visibility_pieces: None, surface: None, + plane: None, } } diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index f5be7ac4c5..2ad8ade0e4 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -41,9 +41,9 @@ pub use live_paint::{ }; use live_paint::{check_live_paint_options, lp_color, lp_rgb_color}; pub use lookup::{ - EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, - is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, - sources, visible_edge_near, visible_face_at, + EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, LP_JOIN_TOL, SOURCES_NAME, builder_members, connected_edges, drawn_outlines, + edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, + path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at, }; pub use map::{ BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs, diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index e5f696a5a7..b5472659d5 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,5 +1,7 @@ use super::cache::RegionCache; -use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at}; +use super::lookup::{ + connected_edges, flood_faces, is_live_paint, live_paint_at, live_paint_at_where, lp_stroke_paint, visible_edge_near, visible_face_at, +}; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent, @@ -540,7 +542,11 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); /// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. A drag draws /// a marquee, and on release the faces and edges it touches ([`lp_parts_in`]) become the selection; /// with Shift held at the press, each of them leaves the selection if it is selected and joins it -/// otherwise. A click and a marquee change the selection on release, so Escape between the press +/// otherwise. A double-click on a face selects it and the faces of its group that are not parted +/// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end +/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them +/// leaves the selection if it is selected and joins it otherwise. +/// A click and a marquee change the selection on release, so Escape between the press /// and the release (which removes the marquee) leaves the selection as it was. The tool does not /// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer /// or on a locked artboard, or in isolation mode outside the isolated art. Its Tool Options @@ -627,6 +633,18 @@ impl LivePaintSelectionTool { }; (hit, cursor) } + + /// What a double-click at `p` selects, Alt held or not: on the face a click there picks + /// ([`LivePaintSelectionTool::picks_edge`]), that face and the faces of its group a flood from it + /// reaches across edges that are hidden or draw no stroke ([`flood_faces`]); on the edge a click + /// picks, that edge and the edges of its group joined to it end to end through edges that draw + /// with the stroke paint it draws with ([`connected_edges`], [`lp_stroke_paint`]). Empty where a click selects no face or edge. + fn reach_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec { + let Some(edge) = self.picks_edge(alt) else { return vec![] }; + let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] }; + let Some(group) = cx.doc.node(gid) else { return vec![] }; + if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) } + } } /// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and @@ -748,7 +766,16 @@ impl Tool for LivePaintSelectionTool { (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } - _ => vec![], + PointerKind::DoubleClick => { + // The two clicks before it already selected the part under the pointer (or, with + // Shift, toggled it twice); the double-click selects the part with what it reaches. + let ids = self.reach_at(cx, p, ev.mods.alt); + match (ids.is_empty(), ev.mods.shift) { + (true, _) => vec![], + (false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))], + (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], + } + } } } diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 5e85d9339d..f577732ade 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -104,6 +104,349 @@ fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, to best.map(|b| b.1) } +/// The greatest distance (pt) between the ends of two Live Paint edges that the Live Paint tools take +/// as one point. Every edge comes from one planar map, so their ends meet this closely. +pub const LP_JOIN_TOL: f64 = 1e-4; + +/// The greatest distance (pt) between the middle of a Live Paint face's outline segment and an edge +/// it runs along. The planar map refits outlines to [`vectorcraft_pathops::DEFAULT_PRECISION`], +/// keeping each refit segment's ends, and with Gap Detection on, the faces and the edges come from +/// two maps, so an outline can lie that far off its edges between its ends. +const LP_FACE_EDGE_TOL: f64 = vectorcraft_pathops::DEFAULT_PRECISION; + +/// The cell [`LP_JOIN_TOL`] wide that holds `p`, for finding the points near it; two points within +/// [`LP_JOIN_TOL`] of each other lie in the same or neighboring cells. The cast saturates, so a +/// coordinate too large or not finite does not overflow. +fn lp_cell(p: Point) -> (i64, i64) { + ((p.x / LP_JOIN_TOL).round() as i64, (p.y / LP_JOIN_TOL).round() as i64) +} + +/// The cell that holds `p` ([`lp_cell`]) and the eight cells around it. +fn lp_cells_around(p: Point) -> impl Iterator { + let (x, y) = lp_cell(p); + (-1..=1).flat_map(move |dx: i64| (-1..=1).map(move |dy: i64| (x.saturating_add(dx), y.saturating_add(dy)))) +} + +/// The stroke paint Live Paint edge `edge` draws with: the paint of its topmost stroke that is shown +/// with a paint, a weight above 0 and an opacity above 0, or [`Paint::None`] when the edge is fully +/// transparent or has no such stroke. +pub(super) fn lp_stroke_paint(edge: &Node) -> Paint { + if edge.opacity <= 0.0 { + return Paint::None; + } + edge.appearance + .items + .iter() + .rev() + .find_map(|i| match i { + vectorcraft_doc::appearance::AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0 && s.opacity > 0.0 => { + Some(s.paint.clone()) + } + _ => None, + }) + .unwrap_or(Paint::None) +} + +/// Whether Live Paint edge `edge` draws no stroke ([`lp_stroke_paint`] is [`Paint::None`]). +fn unstroked(edge: &Node) -> bool { + lp_stroke_paint(edge).is_none() +} + +/// The segments of `pd`. +fn lp_segments(pd: &PathData) -> Vec { + pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect() +} + +/// The faces of Live Paint group `group` that a flood from face `start` reaches, `start` first: +/// `start`, and each face that shares with a face already reached an edge that is hidden or draws +/// no stroke. Hidden faces are not reached and do not pass the flood on. A face runs along the parts +/// of the edge segments its outline segments lie on ([`lp_lies_on`]) or overlap ([`lp_overlap`]). +/// Two faces share an edge where those parts overlap along more than [`LP_FACE_EDGE_TOL`], or, where +/// both outlines lie within [`LP_JOIN_TOL`] of the edge, along more than half the shorter part. So +/// two faces that a Gap Detection gap parts on the same side of an edge, whose parts only touch, do +/// not share it, and two faces whose shared edge is shorter than [`LP_FACE_EDGE_TOL`] do. Empty when +/// `start` is not a face of `group` that is not hidden. +pub fn flood_faces(group: &Node, start: NodeId) -> Vec { + use kurbo::{ParamCurve, ParamCurveArclen}; + use std::collections::{HashMap, HashSet}; + let shown: Vec<&Arc> = faces(group).into_iter().filter(|f| f.visible).collect(); + if !shown.iter().any(|f| f.id == start) { + return vec![]; + } + // Each edge segment, and whether a flood crosses it. + let edge_segs: Vec<(kurbo::CubicBez, bool)> = edges(group) + .into_iter() + .flat_map(|e| { + let cross = !e.visible || unstroked(e); + e.path_data().map(lp_segments).unwrap_or_default().into_iter().map(move |s| (s, cross)) + }) + .collect(); + // The faces along each edge segment a flood crosses, each with the part of the segment it runs + // along (from and to, in the segment's own parameter). A face outline segment usually lies on + // one edge segment, stroked or not ([`lp_lies_on`]); one that does not (a piece of an edge + // segment where a Gap Detection line meets it, the line itself, or an outline segment that spans + // several edge segments) is measured against the edge segments a flood crosses that its bounds + // meet ([`lp_overlap`]). Each part also records whether its outline segment lies within + // [`LP_JOIN_TOL`] of it ([`lp_close`]). + let all: Vec<(kurbo::CubicBez, bool)> = edge_segs.iter().map(|&(s, _)| (s, true)).collect(); + let all_grid = LpEdgeGrid::new(&all); + let mut along: HashMap> = HashMap::new(); + let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![]; + for f in &shown { + for piece in f.path_data().map(lp_segments).unwrap_or_default() { + let m = piece.eval(0.5); + let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL); + // The edge segment the outline segment lies on whose middle is nearest its own. + let on = all_grid + .near_box(bounds) + .into_iter() + .filter_map(|k| edge_segs.get(k).and_then(|(s, cross)| lp_lies_on(s, &piece).map(|part| (k, s, *cross, part)))) + .min_by(|x, y| (x.1.eval(0.5) - m).hypot().total_cmp(&(y.1.eval(0.5) - m).hypot())); + match on { + Some((k, s, cross, (from, to))) => { + if cross { + along.entry(k).or_default().push((f.id, from, to, lp_close(s, from, to, &piece))); + } + } + None => pieces.push((piece, f.id)), + } + } + } + if !pieces.is_empty() { + let grid = LpEdgeGrid::new(&edge_segs); + for (piece, id) in pieces { + let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL); + for k in grid.near_box(bounds) { + if let Some((s, true)) = edge_segs.get(k) + && let Some((a, c)) = lp_overlap(s, &piece) + { + along.entry(k).or_default().push((id, a, c, lp_close(s, a, c, &piece))); + } + } + } + } + let mut next: HashMap> = HashMap::new(); + // Sorted by where each part starts, a part is compared only with the parts after it that start + // before it ends. Two parts that overlap along [`LP_FACE_EDGE_TOL`] or less join only when both + // lie within [`LP_JOIN_TOL`] of the edge and the overlap is more than half the shorter part: an + // edge that short is shared, while an outline that only crosses it lies off it. + for (k, on) in &mut along { + let Some((s, _)) = edge_segs.get(*k) else { continue }; + on.sort_by(|x, y| x.1.total_cmp(&y.1)); + for (i, &(a, a0, a1, a_close)) in on.iter().enumerate() { + for &(c, c0, c1, c_close) in on.iter().skip(i + 1).take_while(|x| x.1 < a1) { + let (from, to) = (a0.max(c0), a1.min(c1)); + let len = |x: f64, y: f64| s.subsegment(x..y).arclen(1e-6); + let shared = if a != c && from < to { len(from, to) } else { 0.0 }; + let short_shared = a_close && c_close && shared > 0.5 * len(a0, a1).min(len(c0, c1)); + if shared > 0.0 && (shared > LP_FACE_EDGE_TOL || short_shared) { + next.entry(a).or_default().push(c); + next.entry(c).or_default().push(a); + } + } + } + } + let mut reached = vec![start]; + let mut seen = HashSet::from([start]); + let mut i = 0; + while let Some(&f) = reached.get(i) { + i += 1; + for &n in next.get(&f).map(Vec::as_slice).unwrap_or_default() { + if seen.insert(n) { + reached.push(n); + } + } + } + reached +} + +/// The part of edge segment `s` that outline segment `piece` runs along, as (from, to) in `s`'s +/// own parameter, or None when they share no length. The part runs between the ends of `s` that lie +/// within [`LP_FACE_EDGE_TOL`] of `piece` and the ends of `piece` that lie within [`LP_JOIN_TOL`] of +/// `s` (the map keeps the ends of an outline segment it refits), and its middle lies within +/// [`LP_FACE_EDGE_TOL`] of `piece`, where the two run along each other ([`lp_runs_along`]). When no +/// end of `piece` lies on `s`, `piece` must lie within [`LP_JOIN_TOL`] of the part ([`lp_close`]). So +/// two segments that only cross or touch, or that only lie near each other, share nothing. +fn lp_overlap(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> { + use kurbo::{ParamCurve, ParamCurveNearest}; + let on_piece = |p: Point| piece.nearest(p, 1e-9).distance_sq.sqrt() <= LP_FACE_EDGE_TOL; + let mut ts: Vec = [(s.p0, 0.0), (s.p3, 1.0)].into_iter().filter(|(p, _)| on_piece(*p)).map(|(_, t)| t).collect(); + let mut piece_end_on = false; + for p in [piece.p0, piece.p3] { + let n = s.nearest(p, 1e-9); + if n.distance_sq.sqrt() <= LP_JOIN_TOL { + ts.push(n.t); + piece_end_on = true; + } + } + let from = ts.iter().copied().fold(f64::INFINITY, f64::min); + let to = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if from >= to { + return None; + } + let mid = (from + to) / 2.0; + let n = piece.nearest(s.eval(mid), 1e-9); + let along = if piece_end_on { lp_runs_along(s, from, to, piece, mid, n.t) } else { lp_close(s, from, to, piece) }; + (n.distance_sq.sqrt() <= LP_FACE_EDGE_TOL && along).then_some((from, to)) +} + +/// The part of edge segment `s` that outline segment `piece` lies on as a whole, as (from, to) in +/// `s`'s own parameter: both ends of `piece` lie within [`LP_JOIN_TOL`] of `s`, its middle lies +/// within [`LP_FACE_EDGE_TOL`] of `s`, and the two run along each other ([`lp_runs_along`]). +fn lp_lies_on(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> { + use kurbo::{ParamCurve, ParamCurveNearest}; + let (a, c, m) = (s.nearest(piece.p0, 1e-9), s.nearest(piece.p3, 1e-9), s.nearest(piece.eval(0.5), 1e-9)); + let (from, to) = (a.t.min(c.t), a.t.max(c.t)); + let near = a.distance_sq.sqrt() <= LP_JOIN_TOL && c.distance_sq.sqrt() <= LP_JOIN_TOL && m.distance_sq.sqrt() <= LP_FACE_EDGE_TOL; + (near && lp_runs_along(s, from, to, piece, m.t, 0.5)).then_some((from, to)) +} + +/// Whether outline segment `piece` runs along the part of edge segment `s` from `from` to `to` +/// rather than crossing it: it lies within [`LP_JOIN_TOL`] of that part ([`lp_close`]), or the two +/// run the same way or opposite ways at `ts` on `s` and `tp` on `piece` ([`lp_parallel`]). +fn lp_runs_along(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez, ts: f64, tp: f64) -> bool { + lp_close(s, from, to, piece) || lp_parallel(s, ts, piece, tp) +} + +/// Whether outline segment `piece` lies within [`LP_JOIN_TOL`] of edge segment `s` at the ends and +/// the middle of the part of `s` from `from` to `to`. +fn lp_close(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez) -> bool { + use kurbo::{ParamCurve, ParamCurveNearest}; + [from, (from + to) / 2.0, to].into_iter().all(|t| piece.nearest(s.eval(t), 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL) +} + +/// Whether segment `s` at parameter `ts` and segment `piece` at parameter `tp` run the same way or +/// opposite ways: the sine of the angle between their directions there is at most 0.2. False where +/// either has no direction. +fn lp_parallel(s: &kurbo::CubicBez, ts: f64, piece: &kurbo::CubicBez, tp: f64) -> bool { + use kurbo::{ParamCurve, ParamCurveDeriv}; + let (u, v) = (s.deriv().eval(ts).to_vec2(), piece.deriv().eval(tp).to_vec2()); + let n = u.hypot() * v.hypot(); + n > 0.0 && u.cross(v).abs() <= 0.2 * n +} + +/// Most grid cells one edge segment is listed in by [`LpEdgeGrid`]; a longer one is tried for +/// every point. +const LP_GRID_SPAN: i64 = 1024; + +/// The farthest cell from the grid's origin, on each axis, that [`LpEdgeGrid`] numbers a point in. +const LP_GRID_LIMIT: f64 = 1_048_576.0; + +/// The edge segments a flood crosses, by the cells of a square grid (about one cell per segment) +/// that their bounds, widened by [`LP_FACE_EDGE_TOL`], cover. +struct LpEdgeGrid { + origin: Point, + cell: f64, + /// Segment indices by cell. + cells: std::collections::HashMap<(i64, i64), Vec>, + /// Segments whose bounds cover more than [`LP_GRID_SPAN`] cells, near every point. + wide: Vec, +} + +impl LpEdgeGrid { + /// The grid of the segments of `edges` that a flood crosses (those marked true). + fn new(edges: &[(kurbo::CubicBez, bool)]) -> Self { + let boxes: Vec<(usize, Rect)> = edges + .iter() + .enumerate() + .filter(|(_, e)| e.1) + .map(|(k, (s, _))| (k, kurbo::ParamCurveExtrema::bounding_box(s).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL))) + .collect(); + let all = boxes.iter().map(|b| b.1).reduce(|a, c| a.union(c)).unwrap_or_default(); + let side = (boxes.len() as f64).sqrt().ceil().max(1.0); + let cell = all.width().max(all.height()) / side; + let cell = if cell.is_finite() && cell > LP_FACE_EDGE_TOL { cell } else { LP_FACE_EDGE_TOL }; + let mut grid = Self { origin: all.origin(), cell, cells: std::collections::HashMap::new(), wide: vec![] }; + for (k, b) in boxes { + let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1))); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN { + grid.wide.push(k); + continue; + } + for x in x0..=x1 { + for y in y0..=y1 { + grid.cells.entry((x, y)).or_default().push(k); + } + } + } + grid + } + + /// The cell `p` is in, counted from the origin (at most [`LP_GRID_LIMIT`] cells away on each + /// axis). + fn cell_of(&self, p: Point) -> (i64, i64) { + let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-LP_GRID_LIMIT, LP_GRID_LIMIT) as i64; + (at(p.x, self.origin.x), at(p.y, self.origin.y)) + } + + /// The segments whose widened bounds may meet `r`, each once, in ascending order. When `r` + /// covers more than [`LP_GRID_SPAN`] cells, every listed segment is returned. + fn near_box(&self, r: Rect) -> Vec { + let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(r.x0, r.y0)), self.cell_of(Point::new(r.x1, r.y1))); + let mut out: Vec = self.wide.clone(); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN { + out.extend(self.cells.values().flatten()); + } else { + for x in x0..=x1 { + for y in y0..=y1 { + out.extend(self.cells.get(&(x, y)).into_iter().flatten()); + } + } + } + out.sort_unstable(); + out.dedup(); + out + } +} + +/// The edges of Live Paint group `group` that are not hidden and are joined to edge `start` through +/// edges that draw with stroke paint `paint` ([`lp_stroke_paint`]), `start` first: `start`, and each +/// edge that draws with that stroke paint and has an end (the first or last point of one of its subpaths) within [`LP_JOIN_TOL`] of an +/// end of an edge already reached. +/// Empty when `start` is not an edge of `group` that is not hidden. +pub fn connected_edges(group: &Node, start: NodeId, paint: &Paint) -> Vec { + use std::collections::{HashMap, HashSet}; + let es: Vec<&Arc> = edges(group).into_iter().filter(|e| e.visible && (e.id == start || lp_stroke_paint(e) == *paint)).collect(); + // The first and last points of each subpath of each edge. + let ends: Vec> = es + .iter() + .map(|e| { + let Some(pd) = e.path_data() else { return vec![] }; + pd.subpaths + .iter() + .filter_map(|sp| { + let n = sp.segment_count(); + (n > 0).then(|| [sp.segment(0).p0, sp.segment(n - 1).p3]) + }) + .flatten() + .collect() + }) + .collect(); + let mut at: HashMap<(i64, i64), Vec<(Point, usize)>> = HashMap::new(); + for (i, pts) in ends.iter().enumerate() { + for &p in pts { + at.entry(lp_cell(p)).or_default().push((p, i)); + } + } + let Some(first) = es.iter().position(|e| e.id == start) else { return vec![] }; + let mut reached = vec![first]; + let mut seen = HashSet::from([first]); + let mut k = 0; + while let Some(&i) = reached.get(k) { + k += 1; + for &p in ends.get(i).map(Vec::as_slice).unwrap_or_default() { + for c in lp_cells_around(p) { + for &(q, j) in at.get(&c).map(Vec::as_slice).unwrap_or_default() { + if (q - p).hypot() <= LP_JOIN_TOL && seen.insert(j) { + reached.push(j); + } + } + } + } + } + reached.into_iter().filter_map(|i| es.get(i).map(|e| e.id)).collect() +} + /// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). pub fn live_paint_at(doc: &Document, p: Point) -> Option { live_paint_at_where(doc, p, |id| doc.is_visible(id)) diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 90e4832771..4c9da58425 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1733,6 +1733,583 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); } +/// A Live Paint group of three faces in a row, A (0..50), B (50..100) and C (100..150), 50 pt tall, +/// with these edges: AB between A and B, with no stroke; BC between B and C, stroked red; the tops +/// of A and B stroked black; the top of C stroked red; and one bottom edge under all three, +/// stroked black. Returns the group, the faces [A, B, C] and the edges [AB, BC, top A, top B, +/// top C, bottom]. +fn live_strip(d: &mut Document) -> (NodeId, [NodeId; 3], [NodeId; 6]) { + let l = d.layers[0].id; + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let mut kids = vec![empty_sources(d)]; + let mut fs = [NodeId(0); 3]; + for (k, x) in [0.0, 50.0, 100.0].into_iter().enumerate() { + fs[k] = d.alloc_id(); + let mut face = Node::path(fs[k], shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + } + let pt = Point::new; + let lines = [ + (pt(50.0, 0.0), pt(50.0, 50.0), None), + (pt(100.0, 0.0), pt(100.0, 50.0), Some(red.clone())), + (pt(0.0, 0.0), pt(50.0, 0.0), Some(black.clone())), + (pt(50.0, 0.0), pt(100.0, 0.0), Some(black.clone())), + (pt(100.0, 0.0), pt(150.0, 0.0), Some(red)), + (pt(0.0, 50.0), pt(150.0, 50.0), Some(black)), + ]; + let mut es = [NodeId(0); 6]; + for (k, (from, to, stroke)) in lines.into_iter().enumerate() { + es[k] = d.alloc_id(); + let mut a = Appearance::default(); + if let Some(s) = stroke { + a.set_stroke(s); + } + let mut edge = Node::path(es[k], shapes::line(from, to), a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + let at = d.layers[0].children().map_or(0, |c| c.len()); + d.insert(Some(l), at, g).unwrap(); + (gid, fs, es) +} + +#[test] +fn live_paint_flood_crosses_unstroked_edges_and_stops_at_stroked_ones() { + let mut d = Document::new(500.0, 500.0); + let (g, [a, b, c], [ab, bc, ..]) = live_strip(&mut d); + let group = |d: &Document| d.node(g).unwrap().clone(); + assert_eq!(flood_faces(&group(&d), a), vec![a, b]); + assert_eq!(flood_faces(&group(&d), b), vec![b, a]); + assert_eq!(flood_faces(&group(&d), c), vec![c]); + // A hidden edge does not part the faces either; a hidden face is not reached and does not start + // a flood; an id that is not a face of the group reaches nothing. + let mut hidden = d.clone(); + hidden.node_mut(bc).unwrap().visible = false; + assert_eq!(flood_faces(&group(&hidden), a), vec![a, b, c]); + hidden.node_mut(b).unwrap().visible = false; + assert_eq!(flood_faces(&group(&hidden), a), vec![a]); + assert!(flood_faces(&group(&hidden), b).is_empty()); + assert!(flood_faces(&group(&d), ab).is_empty()); + // An edge whose stroke is hidden, 0 pt wide or fully transparent, or that is itself fully + // transparent, draws no stroke, so the flood crosses it. + use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; + let base = d.node(bc).unwrap().appearance.stroke().unwrap().clone(); + let changes: [fn(&mut StrokeLayer); 3] = [|s| s.visible = false, |s| s.width = 0.0, |s| s.opacity = 0.0]; + for change in changes { + let mut e = d.clone(); + let mut s = base.clone(); + change(&mut s); + e.node_mut(bc).unwrap().appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_))); + e.node_mut(bc).unwrap().appearance.items.push(AppearanceItem::Stroke(s)); + assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]); + } + let mut e = d.clone(); + e.node_mut(bc).unwrap().opacity = 0.0; + assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]); +} + +/// Faces P (0..50) and Q (50..100) above one edge from (0, 0) to (100, 0) and face R (0..100) below +/// it, with no edge between P and Q (as where a Gap Detection line parts them): P and Q each run +/// along part of the edge, and R along all of it. +#[test] +fn live_paint_flood_measures_faces_along_part_of_an_edge() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = [NodeId(0); 3]; + for (k, r) in [Rect::new(0.0, -50.0, 50.0, 0.0), Rect::new(50.0, -50.0, 100.0, 0.0), Rect::new(0.0, 0.0, 100.0, 50.0)].into_iter().enumerate() { + ids[k] = d.alloc_id(); + let mut face = Node::path(ids[k], shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + } + let [p, q, r] = ids; + let mut edge = Node::path(d.alloc_id(), shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + // P reaches R across the edge, and Q through R; P and Q only touch at (50, 0). + assert_eq!(flood_faces(&group, p), vec![p, r, q]); + let mut without_r = d.clone(); + without_r.node_mut(r).unwrap().visible = false; + assert_eq!(flood_faces(without_r.node(gid).unwrap(), p), vec![p]); +} + +/// Face P (0..100) above unstroked edges along y = 0 split at `splits`, whose outline runs along +/// all of them with one segment, and below them one face between each two splits, the faces parted +/// from each other by stroked edges. A flood from any face below reaches P and, through it, the +/// others; this holds when the outline's middle is no edge's middle (30), is the end shared by two +/// edges (50), and is the middle of an edge shorter than the outline (25 and 75). +#[test] +fn live_paint_flood_crosses_an_outline_segment_that_spans_several_edges() { + for splits in [vec![30.0], vec![50.0], vec![25.0, 75.0]] { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut kids = vec![empty_sources(&mut d)]; + let xs: Vec = std::iter::once(0.0).chain(splits.iter().copied()).chain(std::iter::once(100.0)).collect(); + let add_face = |d: &mut Document, kids: &mut Vec>, r: Rect| { + let id = d.alloc_id(); + let mut face = Node::path(id, shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + id + }; + let p = add_face(&mut d, &mut kids, Rect::new(0.0, -50.0, 100.0, 0.0)); + let below: Vec = xs.windows(2).map(|w| add_face(&mut d, &mut kids, Rect::new(w[0], 0.0, w[1], 50.0))).collect(); + let pt = Point::new; + let lines = xs + .windows(2) + .map(|w| (pt(w[0], 0.0), pt(w[1], 0.0), false)) + .chain(splits.iter().map(|&x| (pt(x, 0.0), pt(x, 50.0), true))) + .collect::>(); + for (from, to, stroked) in lines { + let mut a = Appearance::default(); + if stroked { + a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + } + let mut edge = Node::path(d.alloc_id(), shapes::line(from, to), a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + let mut all = below.clone(); + all.push(p); + all.sort(); + for &f in &below { + let mut got = flood_faces(&group, f); + assert_eq!(got.first(), Some(&f), "{splits:?}"); + got.sort(); + assert_eq!(got, all, "{splits:?} from {f:?}"); + } + } +} + +/// A Live Paint group built from `paths` as `livePaint.make` builds one, with Gap Detection closing +/// gaps up to `gap` pt when given, and with no stroke on any edge. Returns the group and its faces. +fn live_from_paths(paths: Vec, gap: Option) -> (Node, Vec) { + let mut d = Document::new(500.0, 500.0); + let shapes: Vec = paths.into_iter().enumerate().map(|(k, p)| po::Shape::new(p, FillRule::NonZero, k as u64)).collect(); + let (regions, lines) = match gap { + Some(g) => { + let (r, e, _) = po::live_paint_with_gaps(&shapes, g); + (r, e) + } + None => po::live_paint(&shapes), + }; + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = vec![]; + for r in regions { + let id = d.alloc_id(); + let mut face = Node::path(id, r.path, Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + ids.push(id); + } + for e in lines { + let mut edge = Node::path(d.alloc_id(), e.path, Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let mut g = Node::group(d.alloc_id(), kids); + g.name = Some(LIVE_PAINT_NAME.into()); + (g, ids) +} + +/// Live Paint groups built from curves, one with a T-junction where lines end inside an ellipse and +/// one with Gap Detection closing the gap between a curve's end and a circle: with no stroke on any +/// edge, a flood from any face reaches every face. +#[test] +fn live_paint_flood_crosses_unstroked_curves_in_built_groups() { + let pt = Point::new; + let frame = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)); + let t_junction = vec![ + frame, + shapes::ellipse(Rect::new(50.0, 60.0, 150.0, 140.0)), + shapes::line(pt(113.0, 0.0), pt(113.0, 80.0)), + shapes::line(pt(113.0, 200.0), pt(113.0, 120.0)), + ]; + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Smooth }; + let (start, end) = (pt(102.7, 20.0), pt(109.0, 100.0 + (2500.0f64 - 81.0).sqrt() - 2.0)); + let curve = PathData::single(SubPath::new( + vec![a(start, start, start + kurbo::Vec2::new(40.0, 40.0)), a(end, end + kurbo::Vec2::new(-30.0, -40.0), end)], + false, + )); + let gap = vec![shapes::ellipse(Rect::new(50.0, 50.0, 150.0, 150.0)), curve]; + for (name, paths, max_gap, count) in [("T-junction", t_junction, None, 3), ("Gap Detection", gap, Some(3.0), 2)] { + let (g, fs) = live_from_paths(paths, max_gap); + assert_eq!(fs.len(), count, "{name}"); + for &f in &fs { + let mut got = flood_faces(&g, f); + got.sort(); + assert_eq!(got, fs, "{name} from {f:?}"); + } + } +} + +/// A closed outline: `curve`, then straight lines through `rest` and back to the curve's start. +fn curve_then_lines(curve: kurbo::CubicBez, rest: &[Point]) -> PathData { + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + let mut anchors = vec![a(curve.p0, curve.p0, curve.p1), a(curve.p3, curve.p2, curve.p3)]; + anchors.extend(rest.iter().map(|&p| a(p, p, p))); + PathData::single(SubPath::new(anchors, true)) +} + +/// `c` with its two handles moved 0.006 pt along y: its ends stay put and its middle moves 0.0045 pt, +/// as a face outline refit within the planar map's precision can lie off its edge. +fn refit(c: kurbo::CubicBez) -> kurbo::CubicBez { + let up = kurbo::Vec2::new(0.0, 0.006); + kurbo::CubicBez::new(c.p0, c.p1 + up, c.p2 + up, c.p3) +} + +/// A Live Paint group of `faces` and of `lines`, each line with or without a stroke. Returns the +/// group and the ids of the faces. +fn live_of(faces: Vec, lines: Vec<(PathData, bool)>) -> (Node, Vec) { + let mut d = Document::new(500.0, 500.0); + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = vec![]; + for pd in faces { + let id = d.alloc_id(); + let mut face = Node::path(id, pd, Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + ids.push(id); + } + for (pd, stroked) in lines { + let mut a = Appearance::default(); + if stroked { + a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + } + let mut edge = Node::path(d.alloc_id(), pd, a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let mut g = Node::group(d.alloc_id(), kids); + g.name = Some(LIVE_PAINT_NAME.into()); + (g, ids) +} + +/// Faces whose outlines along an unstroked curved edge lie up to 0.0045 pt off it, as the planar map +/// can leave them when it refits an outline across a T-junction or, with Gap Detection on, builds the +/// faces and the edges from two maps: the flood still crosses the edge. Two faces that a gap parts +/// on the same side of the edge, which only touch, are still not joined. +#[test] +fn live_paint_flood_crosses_curved_edges_that_outlines_lie_slightly_off() { + use kurbo::ParamCurve; + let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(30.0, 40.0), Point::new(70.0, 40.0), Point::new(100.0, 0.0)); + let edge = |s: kurbo::CubicBez| { + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + PathData::single(SubPath::new(vec![a(s.p0, s.p0, s.p1), a(s.p3, s.p2, s.p3)], false)) + }; + // T-junction: a stroked line ends on the curve at t = 0.4 and parts the faces below it (L and R); + // the curve is two edges, and the face above (U) runs along both with one refit segment. + let j = c.eval(0.4); + let (left, right) = (c.subsegment(0.0..0.4), c.subsegment(0.4..1.0)); + let (g, ids) = live_of( + vec![ + curve_then_lines(left, &[Point::new(j.x, 80.0), Point::new(0.0, 80.0)]), + curve_then_lines(right, &[Point::new(100.0, 80.0), Point::new(j.x, 80.0)]), + curve_then_lines(refit(c), &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]), + ], + vec![(edge(left), false), (edge(right), false), (shapes::line(j, Point::new(j.x, 80.0)), true)], + ); + let [l, r, u] = [ids[0], ids[1], ids[2]]; + assert_eq!(flood_faces(&g, l), vec![l, u, r]); + let mut from_u = flood_faces(&g, u); + assert_eq!(from_u.first(), Some(&u)); + from_u.sort(); + assert_eq!(from_u, ids); + // Gap Detection: a gap line, which is no edge, parts P and Q above the curve at t = 0.5; the curve + // is one edge, and Q and the face below (B) run along it with refit segments. + let m = c.eval(0.5); + let (g, ids) = live_of( + vec![ + curve_then_lines(c.subsegment(0.0..0.5), &[Point::new(m.x, -50.0), Point::new(0.0, -50.0)]), + curve_then_lines(refit(c.subsegment(0.5..1.0)), &[Point::new(100.0, -50.0), Point::new(m.x, -50.0)]), + curve_then_lines(refit(c), &[Point::new(100.0, 80.0), Point::new(0.0, 80.0)]), + ], + vec![(edge(c), false)], + ); + let [p, q, b] = [ids[0], ids[1], ids[2]]; + assert_eq!(flood_faces(&g, p), vec![p, b, q]); + let mut without_b = g.clone(); + if let Some(kids) = without_b.children_mut() + && let Some(n) = kids.iter_mut().find(|n| n.id == b) + { + Arc::make_mut(n).visible = false; + } + assert_eq!(flood_faces(&without_b, p), vec![p]); +} + +/// Two large faces whose only shared edge, with no stroke, is 0.005 pt long: the flood crosses it. +#[test] +fn live_paint_flood_crosses_a_shared_edge_shorter_than_the_precision() { + let pt = Point::new; + let (a, b) = (pt(50.0, 0.0), pt(50.0, 0.005)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[pt(0.0, -50.0), a, b, pt(0.0, 50.0)], true)), + PathData::single(SubPath::polyline(&[pt(100.0, -50.0), pt(100.0, 50.0), b, a], true)), + ], + vec![(shapes::line(a, b), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// A stroked edge along y = 0 and an edge with no stroke 0.008 pt below it, with a face above the +/// first whose outline ends on it and bows 0.005 pt off it in the middle, nearer the second, and a +/// face below the second: the stroked edge parts the two faces, so the flood does not join them. +#[test] +fn live_paint_flood_stops_where_a_stroked_edge_runs_along_an_unstroked_one() { + let pt = Point::new; + let bow = kurbo::CubicBez::new(pt(100.0, 0.0), pt(70.0, 0.02 / 3.0), pt(30.0, 0.02 / 3.0), pt(0.0, 0.0)); + let (g, ids) = live_of( + vec![ + curve_then_lines(bow, &[pt(0.0, -50.0), pt(100.0, -50.0)]), + PathData::single(SubPath::polyline(&[pt(0.0, 0.008), pt(0.0, 50.0), pt(100.0, 50.0), pt(100.0, 0.008)], true)), + ], + vec![(shapes::line(pt(0.0, 0.0), pt(100.0, 0.0)), true), (shapes::line(pt(0.0, 0.008), pt(100.0, 0.008)), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]); + assert_eq!(flood_faces(&g, ids[1]), vec![ids[1]]); +} + +/// An edge with no stroke from (10, 0) to (90, 0), and two faces that meet on a line 0.005 pt below +/// it from (0, 0.005) to (100, 0.005) (as a Gap Detection line): neither face runs along the edge, so +/// the flood does not join them. +#[test] +fn live_paint_flood_does_not_cross_an_edge_beside_a_line_two_faces_meet_on() { + let pt = Point::new; + let (a, b) = (pt(0.0, 0.005), pt(100.0, 0.005)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[a, b, pt(100.0, -50.0), pt(0.0, -50.0)], true)), + PathData::single(SubPath::polyline(&[b, a, pt(0.0, 50.0), pt(100.0, 50.0)], true)), + ], + vec![(shapes::line(pt(10.0, 0.0), pt(90.0, 0.0)), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]); +} + +/// A stroked edge and an edge with no stroke that leave one point at a slope of 0.01, so they lie +/// within 0.01 pt of each other for their first point or so: the face between them and the face +/// beyond the edge with no stroke share that edge, so the flood joins them. +#[test] +fn live_paint_flood_crosses_an_unstroked_edge_beside_a_stroked_one() { + let pt = Point::new; + let (o, a, b) = (pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 1.0)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[o, a, b], true)), + PathData::single(SubPath::polyline(&[o, b, pt(100.0, 50.0), pt(0.0, 50.0)], true)), + ], + vec![(shapes::line(o, a), true), (shapes::line(o, b), false), (shapes::line(a, b), true)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// Two faces on either side of an edge with no stroke whose curve has a cusp at its middle (no +/// direction there): the flood crosses it. +#[test] +fn live_paint_flood_crosses_an_edge_with_a_cusp_at_its_middle() { + let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0), Point::new(100.0, 0.0)); + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + let edge = PathData::single(SubPath::new(vec![a(c.p0, c.p0, c.p1), a(c.p3, c.p2, c.p3)], false)); + let (g, ids) = live_of( + vec![ + curve_then_lines(c, &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]), + curve_then_lines(c, &[Point::new(100.0, 100.0), Point::new(0.0, 100.0)]), + ], + vec![(edge, false)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// Two faces parted by a line that is no edge (as a Gap Detection line), which crosses an edge with +/// no stroke: one 0.006 pt long, upright or at a slope of 0.1, and one 1 pt long at a slope of 0.09. +/// The faces only cross the edge and do not run along it, so the flood does not join them. +#[test] +fn live_paint_flood_does_not_cross_an_edge_an_outline_only_crosses() { + let pt = Point::new; + let short = pt(0.006, 0.0); + for (a, b, end) in + [(pt(0.003, -0.004), pt(0.003, 0.02), short), (pt(-0.037, -0.004), pt(0.043, 0.004), short), (pt(0.1, -0.009), pt(0.3, 0.009), pt(1.0, 0.0))] + { + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[a, b, pt(-50.0, 50.0), pt(-50.0, -50.0)], true)), + PathData::single(SubPath::polyline(&[b, a, pt(50.0, -50.0), pt(50.0, 50.0)], true)), + ], + vec![(shapes::line(pt(0.0, 0.0), end), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]], "{a:?} to {b:?}"); + } +} + +#[test] +fn live_paint_connected_edges_follow_the_same_stroke_paint() { + let mut d = Document::new(500.0, 500.0); + let (g, [a, ..], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let group = d.node(g).unwrap().clone(); + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + // Top A and top B join end to end; top C joins them but is red, and the black bottom does not + // join them. + assert_eq!(connected_edges(&group, top_a, &black), vec![top_a, top_b]); + // BC and top C, both red, join at (100, 0). + assert_eq!(connected_edges(&group, bc, &red), vec![bc, top_c]); + assert_eq!(connected_edges(&group, bottom, &black), vec![bottom]); + // The edge started from is taken whatever its paint. + assert_eq!(connected_edges(&group, ab, &Paint::None), vec![ab]); + assert!(connected_edges(&group, a, &black).is_empty()); +} + +/// An edge from (0, 0) through (50, 0) to (100, 0) and a second edge with the same stroke from +/// (50, 0) to (50, 50): the second one ends at an anchor in the middle of the first, not at one of +/// its ends, so the two are not joined end to end. A third edge from (100, 0) is. +#[test] +fn live_paint_connected_edges_join_only_at_path_ends() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let mut kids = vec![empty_sources(&mut d)]; + let pt = Point::new; + let paths = [ + PathData::single(SubPath::polyline(&[pt(0.0, 0.0), pt(50.0, 0.0), pt(100.0, 0.0)], false)), + shapes::line(pt(50.0, 0.0), pt(50.0, 50.0)), + shapes::line(pt(100.0, 0.0), pt(100.0, 50.0)), + ]; + let mut ids = vec![]; + for pd in paths { + let mut a = Appearance::default(); + a.set_stroke(black.clone()); + let id = d.alloc_id(); + let mut edge = Node::path(id, pd, a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + ids.push(id); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + assert_eq!(connected_edges(&group, ids[0], &black), vec![ids[0], ids[2]]); + assert_eq!(connected_edges(&group, ids[1], &black), vec![ids[1]]); +} + +/// Three edges end to end along (0, 0) to (150, 0) under one face: a red one, one drawn red with a +/// hidden blue stroke over it, and one drawn blue with a hidden red stroke over it. Edges join by the +/// paint they draw with, not by a hidden stroke's paint, for `connected_edges` and a double-click. +#[test] +fn live_paint_connected_edges_follow_the_stroke_paint_they_draw_with() { + use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + let mut kids = vec![empty_sources(&mut d)]; + let fid = d.alloc_id(); + let mut face = Node::path(fid, shapes::rectangle(Rect::new(0.0, 0.0, 150.0, 50.0)), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + let pt = Point::new; + let hidden = |p: &Paint| AppearanceItem::Stroke(StrokeLayer { visible: false, ..StrokeLayer::new(p.clone(), 1.0) }); + let shown = |p: &Paint| AppearanceItem::Stroke(StrokeLayer::new(p.clone(), 1.0)); + let edges = [(0.0, vec![shown(&red)]), (50.0, vec![shown(&red), hidden(&blue)]), (100.0, vec![shown(&blue), hidden(&red)])]; + let mut ids = vec![]; + for (x, items) in edges { + let id = d.alloc_id(); + let mut edge = Node::path(id, shapes::line(pt(x, 0.0), pt(x + 50.0, 0.0)), Appearance { items, ..Appearance::default() }); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + ids.push(id); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + assert_eq!(connected_edges(&group, ids[0], &red), vec![ids[0], ids[1]]); + assert_eq!(connected_edges(&group, ids[2], &blue), vec![ids[2]]); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let mut t = LivePaintSelectionTool::default(); + let mut dbl = |x: f64| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, 1.0).with_mods(alt)); + assert_eq!(dbl(75.0), set(&[ids[1], ids[0]])); + assert_eq!(dbl(125.0), set(&[ids[2]])); +} + +#[test] +fn live_paint_selection_double_click_selects_connected_faces_or_edges() { + let mut d = Document::new(500.0, 500.0); + let (_, [a, b, c], [_, _, top_a, top_b, ..]) = live_strip(&mut d); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let dbl = |t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, y).with_mods(m)); + let mut t = LivePaintSelectionTool::default(); + // A face: the faces not parted from it by a stroked edge. + assert_eq!(dbl(&mut t, 25.0, 25.0, Mods::default()), set(&[a, b])); + assert_eq!(dbl(&mut t, 125.0, 25.0, Mods::default()), set(&[c])); + // An edge (Alt): the edges joined to it end to end with its stroke paint. + assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[top_a, top_b])); + // With Shift held, the parts reached leave the selection or join it. + assert_eq!(dbl(&mut t, 75.0, 25.0, shift), toggle(&[b, a])); + assert_eq!(dbl(&mut t, 75.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_b, top_a])); + // Away from the group, a double-click adds nothing to what its clicks did. + assert!(dbl(&mut t, 300.0, 300.0, Mods::default()).is_empty()); + // Select Fills off: the double-click picks the edge nearest the pointer as a click does. + t.set_option("selectFills", &json!(false)); + assert_eq!(dbl(&mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b])); + assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty()); + // Select Strokes off: Alt picks the face. + t.set_option("selectFills", &json!(true)); + t.set_option("selectStrokes", &json!(false)); + assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[a, b])); + // Both off: nothing. + t.set_option("selectFills", &json!(false)); + assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty()); + assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty()); +} + +#[test] +fn live_paint_selection_double_click_skips_locked_groups_and_art_outside_isolation() { + let mut d = Document::new(500.0, 500.0); + let (g, _, _) = live_strip(&mut d); + let (other, _) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let dbl = |d: &Document, iso: Option, x: f64| { + let cx = ToolContext { isolation: iso, ..cx(d, &s, &p) }; + LivePaintSelectionTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, x, 25.0)) + }; + assert!(!dbl(&d, None, 25.0).is_empty()); + assert!(dbl(&d, Some(other), 25.0).is_empty(), "outside the isolated art"); + d.node_mut(g).unwrap().locked = true; + assert!(dbl(&d, None, 25.0).is_empty(), "a locked group"); + assert!(!dbl(&d, None, 250.0).is_empty()); +} + /// The Live Paint Selection pointer shows what a click at the point selects, from the point and /// the modifier keys alone (no move first): a face, an edge, or nothing. Groups the tool does not /// take (locked, hidden, outside isolation) show nothing to select. A press keeps its look until diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 584149da8f..85b9cf258e 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -110,14 +110,16 @@ pub enum Cursor { /// The Pencil tool with Alt pressed after the drag starts and Alt key toggles to Smooth Tool off: /// releasing the mouse button with Alt still held closes the path. A drag started with Shift or /// Alt held draws an open straight line instead. Also while a freehand stroke is drawn back near - /// its start, where releasing the mouse button closes the path. A crosshair with a ring. + /// its start, or a stroke continuing a selected path near that path's other end, where + /// releasing the mouse button closes the path. A crosshair with a ring. PencilClose, /// The Pencil tool before a press that draws a straight line (Shift or Alt held). A crosshair with a - /// short straight line. + /// short level line. PencilStraight, - /// The Pencil tool before a press that draws a straight line (Shift or Alt held), over an end of - /// a selected open path: the line continues the path from that end. A crosshair with a short - /// straight line leaving a square end point. + /// The Pencil tool before a press over an end of a selected open path: the stroke, freehand or a + /// straight line, continues the path from that end. Also while a continuing stroke ends near an + /// open end of another path selected at the press, where releasing the mouse button connects the + /// two. A crosshair with a short line rising to the right. PencilContinue, /// The Paintbrush tool. Paintbrush, diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 674ecebf60..51756bf0fd 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -629,7 +629,9 @@ hotspot = "The lead tip." [[cursor]] id = "PencilNew" group = "Drawing and editing tools" -shown_when = ["The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star."] +shown_when = [ + "The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star.", +] hotspot = "The lead tip." look = "The Pencil cursor with a small star badge, where PencilClose has its ring." @@ -639,6 +641,7 @@ group = "Drawing and editing tools" shown_when = [ "The Pencil tool with Alt pressed after the drag starts, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path. A drag started with Shift or Alt held draws an open straight line and does not show this cursor.", "The Pencil tool while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.", + "The Pencil tool while a stroke continuing a selected open path, freehand or a straight line, is drawn to within Close Paths When Ends Are Within of that path's other end: releasing the mouse button there closes the path.", ] hotspot = "The lead tip." look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." @@ -646,16 +649,21 @@ look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." [[cursor]] id = "PencilStraight" group = "Drawing and editing tools" -shown_when = ["The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone."] +shown_when = [ + "The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone.", +] hotspot = "The lead tip." -look = "The Pencil cursor with a short straight-line badge, where PencilClose has its ring." +look = "The Pencil cursor with a short level-line badge, where PencilClose has its ring." [[cursor]] id = "PencilContinue" group = "Drawing and editing tools" -shown_when = ["The Pencil tool over an end of a selected open path, within Edit Selected Paths, with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line that continues the path from that end."] +shown_when = [ + "The Pencil tool over an end of a selected open path, within Edit Selected Paths, before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press continues the path from that end, freehand, or with a straight line when Shift or Alt is held.", + "The Pencil tool while a stroke continuing a selected path ends within Edit Selected Paths of an open end of another path selected at the press: releasing the mouse button connects the two.", +] hotspot = "The lead tip." -look = "PencilStraight's badge with a small filled square at the line's lower end (the end point the line continues from)." +look = "The Pencil cursor with a short badge line rising to the right, where PencilClose has its ring (the stroke carries on from the path)." [[cursor]] id = "Paintbrush" diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index ff99091153..3f8da166e4 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -240,12 +240,18 @@ fn points_source(cx: &ToolContext, (a, ra): (Point, &Option), (b, rb): impl DimensionTool { /// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as /// a link to its path); else where Smart Guides put it (another object's anchor or centre, in - /// line with one, or for the second point on a construction guide through the first), its - /// guides showing; else on the closest point of the line under the pointer; else `p` itself. + /// line with one, or for the second point square to a segment or grazing a curve from the + /// first with Snap to Perpendicular or Snap to Tangent on, or on a construction guide through + /// a first point picked on an anchor), its guides showing; else on the closest point of the + /// line under the pointer; else `p` itself. A first point not on an anchor has no + /// construction guides through it. /// A point the guides leave where the pointer is (right on a guide line) stays there, and with /// Snap to Pixel or Snap to Grid on a point stays where they place it. fn snap_linear(&mut self, cx: &ToolContext, p: Point) -> (Point, Option) { - let leave = self.points.last().map(|(first, _)| crate::guides::Leave::segment(cx, *first, false)); + let leave = self.points.last().map(|(first, anchor)| match anchor { + Some(_) => crate::guides::Leave::segment(cx, *first, false), + None => crate::guides::Leave::contact(*first), + }); let guided = self.snap.hover(cx, p, &[], leave.as_ref()); if let Some((point, reference)) = anchor_under(cx, p) { self.snap.clear(); @@ -902,10 +908,11 @@ mod tests { } } - /// A point that Smart Guides leave where the pointer already is (the pointer right on a - /// construction guide or in line with a target) stays on the guide the overlays show. It - /// does not move to the nearest point of a segment within the pick's reach. Off the guides, - /// a point near a segment still lands on it. + /// A point that Smart Guides leave where the pointer already is (the pointer right in line + /// with a target) stays on the guide the overlays show. It does not move to the nearest point + /// of a segment within the pick's reach. Off the guides, a point near a segment still lands + /// on it, also straight below the first point: the second point has no construction guide + /// through a first point that is not on an anchor. #[test] fn a_point_on_a_guide_stays_on_the_guide() { let (d, _) = doc_with_rect(); @@ -923,17 +930,22 @@ mod tests { // Off the guides, beside the edge: the closest point of the edge. let (on_edge, reference) = t.snap_linear(&c, Point::new(206.0, 170.5)); assert!(on_edge.distance(Point::new(200.0, 170.5)) < 1e-4 && reference.is_none(), "{on_edge:?}"); - // The first point, above the rectangle, then straight down along the vertical - // construction guide through it, past the right edge. + // The first point, above the rectangle, then straight down past the right edge: no + // vertical guide through the first point holds the second, so beside the edge it lands + // on the edge, and in line with the center it stays in line. click(&mut t, 206.0, 50.0); assert_eq!(t.points, vec![(Point::new(206.0, 50.0), None)]); - for y in [150.0, 170.5, 190.0] { - assert_eq!(t.snap_linear(&c, Point::new(206.0, y)), (Point::new(206.0, y), None), "on the construction guide at y = {y}"); - assert!(!t.snap.guides().is_empty()); + for y in [170.5, 190.0] { + let (q, reference) = t.snap_linear(&c, Point::new(206.0, y)); + assert!(q.distance(Point::new(200.0, y)) < 1e-4 && reference.is_none(), "beside the edge at y = {y}: {q:?}"); } - click(&mut t, 206.0, 170.5); + assert_eq!(t.snap_linear(&c, Point::new(207.0, 300.0)), (Point::new(207.0, 300.0), None), "away from the art"); + assert!(t.snap.guides().is_empty()); + assert_eq!(t.snap_linear(&c, Point::new(206.0, 150.0)), (Point::new(206.0, 150.0), None)); + assert!(!t.snap.guides().is_empty()); + click(&mut t, 206.0, 150.0); let dim = t.pending.as_ref().expect("both points were picked"); - assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 170.5))); + assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 150.0))); // The second point, off the edge, is held against the rectangle's anchors. let Some(DimensionSource::Points { free_b: Some(free), .. }) = &dim.source else { panic!("{:?}", dim.source) }; assert!(matches!(free, FreePoint::Anchors { .. }), "{free:?}"); @@ -950,6 +962,82 @@ mod tests { assert_eq!(DimensionTool::default().snap_linear(&pixel, Point::new(206.0, 170.0)), (Point::new(206.0, 170.0), None)); } + /// After a first point that is not on an anchor, the second point still lands where the line + /// from the first point meets a slanted segment square or grazes a curve, with the + /// "perpendicular" or "tangent" label, at places no construction guide through the first + /// point reaches. With Snap to Perpendicular and Snap to Tangent off it stays at the pointer. + #[test] + fn the_second_point_lands_square_to_a_segment_or_grazing_a_curve() { + use vectorcraft_doc::Appearance; + use vectorcraft_geom::{Rect, shapes}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (line, circle) = (d.alloc_id(), d.alloc_id()); + let slanted = shapes::line(Point::new(300.0, 100.0), Point::new(400.0, 150.0)); + d.insert(Some(l), 0, Node::path(line, slanted, Appearance::default_art())).unwrap(); + let round = shapes::ellipse(Rect::new(420.0, 370.0, 480.0, 430.0)); + d.insert(Some(l), 0, Node::path(circle, round, Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let first = Point::new(300.0, 250.0); + let picked = |c: &ToolContext| { + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(c, &PointerEvent::new(kind, first.x, first.y)); + } + assert_eq!(t.points, vec![(first, None)]); + t + }; + let labeled = |t: &DimensionTool, label: &str| t.snap.guides().iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == label)); + // The foot of the perpendicular from (300, 250) to the line is (360, 130), along no + // construction guide angle from the first point. + let mut t = picked(&c); + let (q, reference) = t.snap_linear(&c, Point::new(361.0, 132.0)); + assert!(q.distance(Point::new(360.0, 130.0)) < 1e-4 && reference.is_none(), "{q:?}"); + assert!(labeled(&t, "perpendicular"), "{:?}", t.snap.guides()); + // Where a line from the first point grazes the circle about (450, 400), radius 30. + let (center, radius) = (Point::new(450.0, 400.0), 30.0); + let away = first - center; + let angle = away.y.atan2(away.x) + (radius / away.hypot()).acos(); + let touch = center + Vec2::new(angle.cos(), angle.sin()) * radius; + let (q, _) = t.snap_linear(&c, touch + Vec2::new(0.5, 0.5)); + assert!((q.distance(center) - radius).abs() < 0.05, "{q:?} is on the circle"); + assert!(((q - first).normalize().dot((q - center).normalize())).abs() < 0.01, "{q:?} grazes the circle"); + assert!(labeled(&t, "tangent"), "{:?}", t.snap.guides()); + // With both options off, the pointer near the foot lands on the closest point of the line + // instead. + let off = ToolContext { snap_perpendicular: false, snap_tangent: false, ..cx(&d, &s, &p) }; + let mut t = picked(&off); + let (q, _) = t.snap_linear(&off, Point::new(361.0, 132.0)); + assert!(q.distance(Point::new(360.0, 130.0)) > 0.5 && !labeled(&t, "perpendicular"), "{q:?}"); + } + + /// After a first point picked on an anchor, the second point still lands on the construction + /// guides through it. + #[test] + fn the_second_point_after_an_anchor_lands_on_construction_guides() { + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &PointerEvent::new(kind, 200.0, 200.0)); + } + assert_eq!(t.points, vec![(Point::new(200.0, 200.0), Some(PointRef { node: id, subpath: 0, anchor: 2 }))]); + // 1.5 units right and below the 45° guide up and to the right of the anchor. + let (q, reference) = t.snap_linear(&c, Point::new(331.0, 72.0)); + assert!(q.distance(Point::new(329.5, 70.5)) < 1e-4 && reference.is_none(), "{q:?}"); + assert!(!t.snap.guides().is_empty()); + // The same offset from a first point off the anchors stays at the pointer. + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &PointerEvent::new(kind, 240.0, 240.0)); + } + assert_eq!(t.points, vec![(Point::new(240.0, 240.0), None)]); + assert_eq!(t.snap_linear(&c, Point::new(371.0, 112.0)), (Point::new(371.0, 112.0), None)); + assert!(t.snap.guides().is_empty()); + } + #[test] fn placement_press_previews_without_a_prior_move() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 8058101b1c..2e93888d02 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -477,7 +477,8 @@ impl Tool for FamilyTool { } "slope" => self.slope = v.as_f64().filter(|n| n.is_finite()).map_or(self.slope, |n| n.clamp(-100.0, 100.0)), "fillArc" => self.fill_arc = v.as_bool().unwrap_or(self.fill_arc), - "decay" => self.decay = v.as_f64().unwrap_or(self.decay).clamp(5.0, 150.0), + // Report and persist the decay that shapes::spiral can actually draw. + "decay" => self.decay = v.as_f64().unwrap_or(self.decay).clamp(5.0, 99.99), "segments" => self.segments = v.as_u64().unwrap_or(10).clamp(2, 1000) as u32, "clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise), "rows" | "concentric" => self.rows = v.as_u64().unwrap_or(5).min(999) as u32, @@ -534,6 +535,55 @@ mod tests { use crate::testutil::*; use vectorcraft_doc::Selection; + #[test] + fn spiral_reports_the_decay_its_command_draws() { + let mut t = FamilyTool::new("spiral"); + assert_eq!(t.options()["decay"], json!(80.0)); + for (requested, expected) in [(-10.0, 5.0), (5.0, 5.0), (80.0, 80.0), (99.99, 99.99), (100.0, 99.99), (150.0, 99.99), (200.0, 99.99)] { + t.set_option("decay", &json!(requested)); + assert_eq!(t.options()["decay"], json!(expected)); + let (id, params) = t.command(Point::ZERO, Point::new(100.0, 0.0), Mods::default()); + assert_eq!(id, "shape.spiral"); + assert_eq!(params["decay"], json!(expected)); + // Inspect the actual adjacent radii, not a second copy of the tool's clamp. Decay is per full + // wind and a segment is a quarter turn, so four segments give the decay. + let path = vectorcraft_geom::shapes::spiral(Point::ZERO, 100.0, params["decay"].as_f64().unwrap(), 2, true); + let anchors = &path.subpaths[0].anchors; + let outer = anchors[2].p.distance(Point::ZERO); + let next = anchors[1].p.distance(Point::ZERO); + assert!((100.0 * (next / outer).powi(4) - expected).abs() < 1e-9); + } + for invalid in [Value::Null, json!("80"), json!(false), json!({})] { + t.set_option("decay", &invalid); + assert_eq!(t.options()["decay"], json!(99.99)); + } + } + + #[test] + fn normalized_spiral_decay_preserves_existing_capped_paths_within_roundoff() { + for requested in [100.0, 150.0] { + let mut t = FamilyTool::new("spiral"); + t.set_option("decay", &json!(requested)); + for clockwise in [false, true] { + for segments in [2, 10, 1000] { + for radius in [1.0, 100.0, 1e6] { + let previous = vectorcraft_geom::shapes::spiral(Point::ZERO, radius, requested, segments, clockwise); + let normalized = vectorcraft_geom::shapes::spiral(Point::ZERO, radius, t.decay, segments, clockwise); + let old = &previous.subpaths[0].anchors; + let new = &normalized.subpaths[0].anchors; + assert_eq!(old.len(), new.len()); + for (a, b) in old.iter().zip(new) { + assert_eq!(a.kind, b.kind); + for (p, q) in [(a.p, b.p), (a.h_in, b.h_in), (a.h_out, b.h_out)] { + assert!(p.distance(q) <= 1e-10 * (1.0 + radius), "{requested}% / {segments} / {radius}: {p:?} != {q:?}"); + } + } + } + } + } + } + } + #[test] fn grid_drag_and_arrows() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 61396e01d9..c3191c4363 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -98,8 +98,13 @@ pub struct GestureTool { /// A release where the pointer last dragged to would close the stroke because it ends near /// its start (Close Paths When Ends Are Within): the cursor shows the closing ring. near_start: bool, + /// A release where the pointer last dragged to would connect the continued path to another + /// selected path's open end (`finish`): the cursor shows the continuation cursor. + connects: bool, /// Pencil/Paintbrush continuing a selected open path: (path, continue from its start). extend: Option<(NodeId, bool)>, + /// The other end of the path `extend` continues: a release near it closes the path. + close_to: Option, /// A Pencil stroke continuing a selected open path: the other paths selected at the press, /// whose open ends it connects to. connect_to: Vec, @@ -817,7 +822,9 @@ impl GestureTool { active: false, began: false, near_start: false, + connects: false, extend: None, + close_to: None, connect_to: vec![], redraw: None, fidelity, @@ -1100,6 +1107,21 @@ impl GestureTool { self.find_end(cx, p, &cx.selection.objects) } + /// The other end of the open end `find_end` found: the opposite end of `id`'s last open + /// subpath. + fn other_end(cx: &ToolContext, id: NodeId, at_start: bool) -> Option { + let pd = cx.doc.node(id)?.path_data()?; + let sp = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty())?; + let a = if at_start { sp.anchors.last() } else { sp.anchors.first() }; + a.map(|a| a.p) + } + + /// Continues the path at the open end `find_extend` found, if any, noting its other end. + fn set_extend(&mut self, cx: &ToolContext, found: Option<(NodeId, bool, Point)>) { + self.extend = found.map(|(id, at_start, _)| (id, at_start)); + self.close_to = found.and_then(|(id, at_start, _)| Self::other_end(cx, id, at_start)); + } + /// [`Self::find_extend`] among `ids`. fn find_end(&self, cx: &ToolContext, p: Point, ids: &[NodeId]) -> Option<(NodeId, bool, Point)> { if self.edit_within <= 0.0 { @@ -1144,16 +1166,32 @@ impl GestureTool { } /// Does a stroke that ends at `end` end near its start (Close Paths When Ends Are Within, off - /// at 0 pixels)? + /// at 0 pixels)? A stroke continuing a selected path ends near that path's other end, a + /// straight line too; a new straight line never closes, as it would run back over itself. fn ends_near_start(&self, cx: &ToolContext, end: Point) -> bool { - self.close_within > 0.0 && self.points.len() > 2 && self.points[0].distance(end) <= cx.tol(self.close_within) + if self.close_within <= 0.0 { + return false; + } + let tol = cx.tol(self.close_within); + match (self.extend, self.close_to) { + (Some(_), Some(to)) => self.points.len() >= 2 && to.distance(end) <= tol, + (Some(_), None) => false, + (None, _) => !self.straight && self.points.len() > 2 && self.points.first().is_some_and(|s| s.distance(end) <= tol), + } } - /// Does a release now, with the stroke ending at `end`, close a freehand stroke because it ends - /// near its start (`finish`)? A stroke that redraws a selected path closes only when the engine - /// finds it does not end on that path, so it counts as not closing. + /// The open end of another path selected at the press that a continuing Pencil stroke ending + /// at `end` connects to (`finish`). + fn connects_at(&self, cx: &ToolContext, end: Point) -> Option<(NodeId, bool, Point)> { + self.extend.and_then(|_| self.find_end(cx, end, &self.connect_to)) + } + + /// Does a release now, with the stroke ending at `end`, close the stroke because it ends near + /// its start, or near the other end of the path it continues (`finish`)? A stroke that redraws + /// a selected path closes only when the engine finds it does not end on that path, so it counts + /// as not closing, and a stroke that connects to another path doesn't close. fn release_closes_near_start(&self, cx: &ToolContext, end: Point) -> bool { - self.draws_path() && !self.smoothing && !self.straight && self.extend.is_none() && self.redraw.is_none() && self.ends_near_start(cx, end) + self.draws_path() && !self.smoothing && self.redraw.is_none() && self.ends_near_start(cx, end) && self.connects_at(cx, end).is_none() } /// Parameters of `path.freehand` for the current stroke, fitted at Fidelity on screen. Only the @@ -1194,14 +1232,16 @@ impl GestureTool { if !self.began { return vec![]; } - // Close Paths When Ends Are Within is off at 0 pixels. + // Close Paths When Ends Are Within is off at 0 pixels. A stroke continuing a + // selected path closes it near its other end, a straight line too. let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end)); - // A straight line has two points: closing it would draw it back over itself. - let closed = !self.straight && ((m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || (self.extend.is_none() && near)); - let mut params = self.freehand_params(cx, closed); + // Alt at the release closes a freehand stroke; a new straight line has two points: + // closing it would draw it back over itself. + let closed = (!self.straight && m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || near; // A Pencil stroke that continues a selected path and ends on an open end of another - // path selected at the press connects the two. - let connect = pts.last().and_then(|&end| self.find_end(cx, end, &self.connect_to)); + // path selected at the press connects the two, and doesn't close. + let connect = pts.last().and_then(|&end| self.connects_at(cx, end)); + let mut params = self.freehand_params(cx, closed && connect.is_none()); if let Some((other, at_start, _)) = connect { params["connect"] = json!({"id": other.0, "end": if at_start { "start" } else { "end" }}); } @@ -1267,6 +1307,7 @@ impl Tool for GestureTool { self.active = true; self.began = false; self.near_start = false; + self.connects = false; // Alt switches the Pencil when its toggle is on, the Blob Brush always, and the // Paintbrush when the press has a selected path. self.smoothing = ev.mods.alt @@ -1278,7 +1319,7 @@ impl Tool for GestureTool { self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && ev.mods.alt); self.unconstrained = self.straight && !ev.mods.shift; let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None }; - self.extend = extend.map(|(id, at_start, _)| (id, at_start)); + self.set_extend(cx, extend); // The stroke's previews select the path it continues, so the others are kept now. self.connect_to = match self.extend { Some((id, _)) if self.id == "pencil" => cx.selection.objects.iter().copied().filter(|o| *o != id).collect(), @@ -1336,16 +1377,20 @@ impl Tool for GestureTool { return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }; } self.points.truncate(1); + // The line ends at `p`, where its end snaps. + end = p; } else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) || self.points.len() >= crate::MAX_STROKE_SAMPLES { // A release here keeps the last point unless it snaps (`Up`), so the ring // follows the point `finish` will test. let kept = self.points.last().copied().filter(|l| l.distance(p) < cx.tol(1.0) && !self.snaps()); let release_end = kept.unwrap_or(end); self.near_start = self.release_closes_near_start(cx, release_end); + self.connects = self.connects_at(cx, release_end).is_some(); return vec![]; } self.push(p, ev); self.near_start = self.release_closes_near_start(cx, end); + self.connects = self.connects_at(cx, end).is_some(); if !self.draws_path() || self.smoothing { return vec![]; } @@ -1406,11 +1451,13 @@ impl Tool for GestureTool { self.active = false; self.began = false; self.near_start = false; + self.connects = false; self.smoothing = false; self.straight = false; self.unconstrained = false; self.clear(); self.extend = None; + self.close_to = None; self.connect_to.clear(); self.redraw = None; out @@ -1440,6 +1487,7 @@ impl Tool for GestureTool { self.active = false; self.began = false; self.near_start = false; + self.connects = false; self.smoothing = false; self.straight = false; self.unconstrained = false; @@ -1470,6 +1518,7 @@ impl Tool for GestureTool { self.snap.clear(); self.active = false; self.near_start = false; + self.connects = false; self.began = false; self.smoothing = false; self.straight = false; @@ -1557,6 +1606,11 @@ impl Tool for GestureTool { if !self.active && self.straight_at_press(m) { return if self.find_extend(cx, p).is_some() { Cursor::PencilContinue } else { Cursor::PencilStraight }; } + // A continuing Pencil stroke that a release would connect to another selected path's open + // end (`finish`) shows that it continues on; `connects` tests the point the release keeps. + if self.id == "pencil" && self.active && self.connects { + return Cursor::PencilContinue; + } // Alt held at the release closes the path (`finish`). // A straight line stays open, so its release has nothing to close, and before a press Alt // gives the Pencil a straight line. @@ -1576,9 +1630,12 @@ impl Tool for GestureTool { "smooth" => smooth_cursor(cx), "pathEraser" => Cursor::PathEraser, "join" => Cursor::Join, + // Before a press over an open end of a selected path the Pencil shows that the stroke + // continues it (`PointerKind::Down`). + _ if !self.active && self.find_extend(cx, p).is_some() => Cursor::PencilContinue, // Before a press that draws a new path the Pencil shows its star; over a selected path - // that the press continues from an end or redraws (`PointerKind::Down`), it does not. - _ if !self.active && self.find_extend(cx, p).is_none() && self.find_redraw(cx, p).is_none() => Cursor::PencilNew, + // that the press redraws (`PointerKind::Down`), it does not. + _ if !self.active && self.find_redraw(cx, p).is_none() => Cursor::PencilNew, _ => Cursor::Pencil, } } diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index ece7a369bb..33d6520b84 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -793,7 +793,7 @@ fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() { } } let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "over an end the stroke continues"); assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws"); assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path"); // While a stroke is drawn, the Pencil's own cursor. @@ -838,7 +838,7 @@ fn pencil_shift_line_continues_a_selected_path_from_its_end() { } /// Before a press over an end of a selected open path, the Pencil shows its continuation cursor -/// exactly when the press would draw a straight line that continues the path; away from the +/// exactly when the press continues the path, freehand or with a straight line; away from the /// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with /// the Paintbrush, it does not. #[test] @@ -859,12 +859,9 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let shift = Mods { shift: true, ..Default::default() }; let alt = Mods { alt: true, ..Default::default() }; let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - // The release's stroke: a straight line that continues the path. - let continues = |acts: &[Vec]| { - acts.last() - .and_then(|a| a.first()) - .is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2))) - }; + // The release's stroke continues the path. + let continues = + |acts: &[Vec]| acts.last().and_then(|a| a.first()).is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some())); for alt_smooth in [false, true] { for m in [Mods::default(), shift, alt, shift_alt] { for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] { @@ -880,7 +877,7 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let end = Point::new(405.0, 303.0); assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue); assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "a freehand stroke continues it too"); assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends"); // While the line is drawn, the Pencil's own cursor. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift)); @@ -897,6 +894,105 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); } +/// A Pencil stroke continuing a selected path shows the continuation cursor while a release would +/// connect it to an open end of another path selected at the press, and the closing ring while a +/// release would close the path because the stroke ends within Close Paths When Ends Are Within +/// of the path's other end, a straight line too; the release does what the cursor showed. +#[test] +fn a_continued_stroke_shows_where_it_connects_or_closes() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, pts: &[(f64, f64)]| { + let id = d.alloc_id(); + let mut els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(pts[0].0, pts[0].1))]; + els.extend(pts[1..].iter().map(|&(x, y)| vectorcraft_geom::PathEl::LineTo(Point::new(x, y)))); + let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + let a = add(&mut d, &[(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)]); + let b = add(&mut d, &[(500.0, 500.0), (600.0, 500.0)]); + let p = paint(); + let mut both = Selection::default(); + both.set([a, b]); + let mut cx = cx(&d, &both, &p); + cx.smart_guides = false; + cx.snap_to_point = false; + let none = Mods::default(); + let shift = Mods { shift: true, ..Default::default() }; + // Runs a stroke from `pts[0]`, giving the cursor at its last point before the release and the + // release's `path.freehand` parameters. + let run = |t: &mut GestureTool, pts: &[(f64, f64)], m: Mods| { + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m)); + for &(x, y) in &pts[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m)); + } + let (x, y) = pts[pts.len() - 1]; + let shown = t.cursor(&cx, Point::new(x, y), m); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + (shown, v.clone()) + }; + let mut t = GestureTool::new("pencil"); + // To the other selected path's start: it connects, and doesn't close. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (450.0, 450.0), (498.0, 502.0)], none); + assert_eq!(shown, Cursor::PencilContinue, "near the other path's end"); + assert_eq!( + (v["extend"].clone(), v["connect"].clone(), v["closed"].clone()), + (json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}), json!(false)) + ); + // Back near the path's own start (10 px from it): the ring, and the release closes it. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none); + assert_eq!(shown, Cursor::PencilClose, "near the path's other end"); + assert_eq!((v["extend"].clone(), v["closed"].clone()), (json!({"id": a.0, "end": "end"}), json!(true))); + assert!(v.get("connect").is_none(), "{v}"); + // Farther than Close Paths When Ends Are Within (15 px): no ring, and it stays open. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (290.0, 330.0)], none); + assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "away from the other end"); + // A straight line from the end to near the start: the ring, and it closes the path. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 350.0), (306.0, 306.0)], shift); + assert_eq!(shown, Cursor::PencilClose, "a straight line near the other end"); + assert_eq!((v["points"].as_array().map(Vec::len), v["closed"].clone()), (Some(2), json!(true))); + // Close Paths When Ends Are Within off: neither ring nor close. + t.set_option("closeWithin", &json!(0.0)); + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none); + assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "at 0 px"); +} + +/// The continuation cursor tests where the release ends, after snapping, not the pointer: here +/// Snap to Grid moves a stroke's end from 13 pt away from another selected path's end (outside Edit +/// Selected Paths' 12 px) to 9 pt away, so the release connects, and the cursor says so. +#[test] +fn the_continuation_cursor_follows_the_snapped_release() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| { + let id = d.alloc_id(); + let els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1))]; + let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + // On the 9 pt grid. + let a = add(&mut d, (306.0, 306.0), (405.0, 405.0)); + let b = add(&mut d, (504.0, 504.0), (603.0, 504.0)); + let p = paint(); + let mut both = Selection::default(); + both.set([a, b]); + let mut cx = cx(&d, &both, &p); + cx.smart_guides = false; + cx.snap_to_point = false; + cx.snap_to_grid = true; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 405.0, 405.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 460.0, 450.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 517.0, 504.0)); + assert_eq!(t.cursor(&cx, Point::new(517.0, 504.0), Mods::default()), Cursor::PencilContinue); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 517.0, 504.0)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["connect"], json!({"id": b.0, "end": "start"}), "{v}"); +} + /// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt /// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. #[test] diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 006f498b6e..d7ee8ecdd8 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -257,10 +257,11 @@ impl Leave { self } - /// The straight segment from the anchor `at` to a point being dragged (an open path's end - /// moved with Direct Selection): the point may land square to a segment or grazing a curve, - /// but is not held to Shift's angles or the construction guides, which a moved anchor - /// does not follow. + /// The straight segment from the point `at` to a point being placed (an open path's end + /// moved with Direct Selection, or the Dimension tool's second Linear point after a first + /// point that is not on an anchor): the point may + /// land square to a segment or grazing a curve, but is not held to Shift's angles or the + /// construction guides through `at`. pub fn contact(at: Point) -> Self { Self { at, shift: None, angles: &[], contact: true } } diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index fa31174afa..e3e6abeef6 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -36,7 +36,8 @@ pub struct Targets { pub(super) xs: Vec<(f64, Point, Kind)>, pub(super) ys: Vec<(f64, Point, Kind)>, /// Path segments and their control bounds, which a point lands on ("path"): gathered only for - /// dragged anchors ([`Self::for_anchor_drag`]). + /// dragged anchors ([`Self::for_anchor_drag`]) and selections moved by an anchor + /// ([`Self::for_move_by_anchor`]). pub(super) segments: Vec<(Rect, PathSeg)>, /// Ruler guides, which pull a point into line where they run (Snap to Point). pub(super) rulers: Vec, @@ -170,7 +171,18 @@ impl Targets { /// Guides › Snap to Last Location, a move (not a copy, which has nothing in `exclude`) goes back /// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel snaps moves. pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { - let mut t = Self::collect(cx.doc, exclude, None).styled(cx); + Self::moving(cx, exclude, Gather::Bounds) + } + + /// [`Self::for_move`]'s targets for a selection moved by one of its anchors (#7411): the path + /// segments of the art but `exclude` too, which that anchor lands on ([`Self::snap_onto_path`]), + /// as a dragged anchor does with Direct Selection. + pub fn for_move_by_anchor(cx: &ToolContext, exclude: &[NodeId]) -> Self { + Self::moving(cx, exclude, Gather::Segments) + } + + fn moving(cx: &ToolContext, exclude: &[NodeId], gather: Gather) -> Self { + let mut t = Self::collect_inner(cx.doc, exclude, None, None, gather).styled(cx); if cx.spacing_guides { t.boxes = object_boxes(cx.doc, &cx.selection.objects); } @@ -858,6 +870,21 @@ impl Targets { self.line(from, q).into_iter().chain(self.label(q, "align")).collect() } + /// A point `p` near no target anchor or center landed where two target paths cross + /// ("intersect"), else on the nearest target path ("path"), within `tol`: the point and its + /// label, as [`Self::snap_point`] lands it. The ruler guides of `rulers` count as paths that + /// cross ([`Self::rulers`]). None when no path is in reach, or the targets hold no segments. + pub(crate) fn snap_onto_path(&self, p: Point, tol: f64, rulers: Option<&Targets>) -> Option<(Point, Vec)> { + if self.segments.is_empty() { + return None; + } + if let Some(q) = self.crossing_with(p, tol, rulers.map_or(&self.rulers, |r| &r.rulers)) { + return Some((q, self.label(q, "intersect").into_iter().collect())); + } + let q = self.on_segment(p, tol)?; + Some((q, self.label(q, Kind::Edge.label()).into_iter().collect())) + } + /// The nearest point within `tol` of `p` on a target segment. fn on_segment(&self, p: Point, tol: f64) -> Option { self.segments @@ -872,6 +899,11 @@ impl Targets { /// Where two target paths, a path and a ruler guide, or two ruler guides cross, the crossing /// nearest `p` within `tol` ("intersect", #1602). fn crossing(&self, p: Point, tol: f64) -> Option { + self.crossing_with(p, tol, &self.rulers) + } + + /// [`Self::crossing`] with the ruler guides `rulers`. + fn crossing_with(&self, p: Point, tol: f64, rulers: &[Ruler]) -> Option { // A ruler guide running past, as a short line through the reach of `p`. let ruler = |r: &Ruler| { let (pos, ..) = r.line_at(p, tol)?; @@ -885,7 +917,7 @@ impl Targets { .iter() .filter(|(b, c)| b.inflate(tol, tol).contains(p) && c.nearest(p, 1e-6).distance_sq <= tol * tol) .map(|(_, c)| *c) - .chain(self.rulers.iter().filter_map(ruler)) + .chain(rulers.iter().filter_map(ruler)) // shortcut: the first 32 lines in reach are paired, raise it if busier crossings matter .take(32) .collect(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 1119bb0764..15fec287b5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -673,7 +673,10 @@ mod cursor_tests { // Away from a selected path the Pencil shows its new-path cursor, drawn with a star; its // own cursor, for drawing and for a selected path under the pointer, borrows the crosshair. assert_eq!(cursor_at("pencil", &d, away), Cursor::PencilNew); - assert_eq!(Cursor::PencilNew.precise(), Cursor::Crosshair); + // Its badged cursors too: Use Precise Cursors shows the plain crosshair. + for c in [Cursor::PencilNew, Cursor::PencilClose, Cursor::PencilStraight, Cursor::PencilContinue, Cursor::PaintbrushClose] { + assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); + } assert_eq!(Cursor::PencilNew.stand_in(), None); assert_eq!(Cursor::Pencil.precise(), Cursor::Crosshair); assert_eq!(Cursor::Pencil.stand_in(), Some(Cursor::Crosshair)); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 70060ebc2b..76e4a5c977 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -94,6 +94,9 @@ enum State { /// The point that snaps, moving with the pointer: `start`, or where a press on a path's /// stroke or beside its edge took hold of the path ([`hold_point`]). hold: Point, + /// The press grabbed the selection by an anchor (`start`): it snaps as + /// [`MoveSnap::by_anchor`] says. + by_anchor: bool, began: bool, /// Shift-pressed on this selected object: released without a drag, it leaves the /// selection. @@ -247,13 +250,24 @@ pub(crate) struct MoveSnap { impl MoveSnap { pub(crate) fn new(cx: &ToolContext) -> Self { + Self::gather(cx, Targets::for_move) + } + + /// [`Self::new`] for a selection grabbed by one of its anchors: with Smart Guides, that anchor + /// also lands on the other art's paths and where they cross (#7411), as Direct Selection lands + /// an anchor it drags. + pub(crate) fn by_anchor(cx: &ToolContext) -> Self { + Self::gather(cx, Targets::for_move_by_anchor) + } + + fn gather(cx: &ToolContext, for_move: fn(&ToolContext, &[NodeId]) -> Targets) -> Self { let guides = cx.smart_guides && !cx.snap_to_grid; let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) }; Self { bounds: selection_bounds(cx), - targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)), + targets: guides.then(|| for_move(cx, &cx.selection.objects)), points: points(&cx.selection.objects), - copy_targets: guides.then(|| Targets::for_move(cx, &[])), + copy_targets: guides.then(|| for_move(cx, &[])), copy_points: points(&[]), rulers: (guides && cx.snap_to_point).then(|| Targets::rulers(cx)), } @@ -286,6 +300,9 @@ impl MoveSnap { } // The axes (x, y) Smart Guides already put in line with other art. let mut taken = [false; 2]; + // The grabbed anchor landed on a path or a crossing, which a ruler guide does not pull + // it off. + let mut on_path = false; if let (Some(t), Some(b)) = (targets, self.bounds) { // The point the selection was grabbed by, near another object's anchor or centre, // lands on it, rather than the bounds lining up a little beside it (#955). @@ -294,6 +311,13 @@ impl MoveSnap { d = q - start; guides = ov; taken = [true; 2]; + } else if let Some((q, ov)) = t.snap_onto_path(start + d, cx.snap_tol(), self.rulers.as_ref()) { + // Grabbed by an anchor ([`Self::by_anchor`]), it lands on a path near it, or where + // the path crosses another or a ruler guide, instead of lining the bounds up. + d = q - start; + guides = ov; + taken = [true; 2]; + on_path = true; } else { let (adj, ov, axes) = t.snap_rects_axes(&[b + d], cx.snap_tol()); d += adj; @@ -304,7 +328,7 @@ impl MoveSnap { // A ruler guide within reach of the grabbed point wins over lining the bounds up; on an // axis without one that Smart Guides left free, the bounds line up with the ruler guides // (#5746), alongside the Smart Guides. - if let Some(t) = &self.rulers { + if let Some(t) = self.rulers.as_ref().filter(|_| !on_path) { let (adj, ov, held) = t.snap_moved_to_rulers(start + d, self.bounds.map(|b| b + d), cx.snap_tol(), taken); if adj != Vec2::ZERO { d += adj; @@ -681,6 +705,15 @@ impl SelectionTool { }) } + /// The point a press `ev` grabs the selection by ([`Self::hover_point`]), and whether it is an + /// anchor. Nothing for other pointer events. + fn grabbed_at(cx: &ToolContext, ev: &PointerEvent) -> (Option, bool) { + match ev.kind { + PointerKind::Down => Self::hover_point(cx, ev.pos).map_or((None, false), |(q, label)| (Some(q), label == "anchor")), + _ => (None, false), + } + } + /// On a press, notes the text the pointer is loaded with ([`Self::loaded_at_press`]); a /// release clears it. fn note_loaded_at_press(&mut self, kind: PointerKind) { @@ -913,7 +946,7 @@ impl Tool for SelectionTool { let m = ev.mods; // A press near a centre or an anchor grabs the object by it. self.hover = None; - let grabbed = if matches!(ev.kind, PointerKind::Down) { Self::hover_point(cx, p).map(|(q, _)| q) } else { None }; + let (grabbed, by_anchor) = Self::grabbed_at(cx, ev); let grab = grabbed.unwrap_or(p); if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) { self.hover = Self::hover_point(cx, p); @@ -939,7 +972,7 @@ impl Tool for SelectionTool { { let several = cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty(); let (deselect, key) = if m.shift { (Some(id), None) } else { (None, (several && !m.alt).then_some(id)) }; - self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect, key, exclude: None }; + self.state = State::Moving { start: p, press: p, hold: center, by_anchor: false, began: false, deselect, key, exclude: None }; return vec![]; } // 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling). @@ -947,7 +980,8 @@ impl Tool for SelectionTool { && cx.select_behind && let Some(behind) = object_behind(cx, p) { - self.state = State::Moving { start: p, press: p, hold: p, began: false, deselect: None, key: None, exclude: None }; + self.state = + State::Moving { start: p, press: p, hold: p, by_anchor: false, began: false, deselect: None, key: None, exclude: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } let (hit, scope) = match selected_source_hit(cx, p) { @@ -969,17 +1003,17 @@ impl Tool for SelectionTool { // Grabbed by no anchor or centre, a press on a path's stroke or beside its // edge holds the path there, and that point of it snaps (#3701). let hold = if grabbed.is_some() { grab } else { hold_point(cx, &h, p) }; - self.state = State::Moving { start: grab, press: p, hold, began: false, deselect, key, exclude }; + self.state = State::Moving { start: grab, press: p, hold, by_anchor, began: false, deselect, key, exclude }; out } None => { - self.press_nothing(cx, p, grab, m); + self.press_nothing(cx, p, grab, by_anchor, m); vec![] } } } - (PointerKind::Drag, State::Moving { start, press, hold, began, exclude, .. }) => { - self.drag_moving(cx, p, m, ev.backquote, (start, press, hold, began), exclude) + (PointerKind::Drag, State::Moving { start, press, hold, by_anchor, began, exclude, .. }) => { + self.drag_moving(cx, p, m, ev.backquote, (start, press, hold, by_anchor, began), exclude) } (PointerKind::Drag, State::Scaling { handle, bx, areas }) => { self.last_backquote = ev.backquote; @@ -1679,9 +1713,10 @@ impl SelectionTool { /// A press at `p` that hits no object starts a marquee, except where a clipping mask hides /// the art of a selected object (picked in the Layers panel, say): there, without Shift, it /// grabs the selection by that art (at `grab`), as a press on its visible part does. - fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, m: Mods) { + /// `by_anchor`: `grab` is an anchor of it. + fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, by_anchor: bool, m: Mods) { self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() { - State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None, exclude: None } + State::Moving { start: grab, press: p, hold: grab, by_anchor, began: false, deselect: None, key: None, exclude: None } } else { State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false, directional_inside: false } }; @@ -1693,7 +1728,7 @@ impl SelectionTool { p: Point, m: Mods, backquote: bool, - (start, press, hold, began): (Point, Point, Point, bool), + (start, press, hold, by_anchor, began): (Point, Point, Point, bool, bool), exclude: Option<(NodeId, NodeId)>, ) -> Vec { let mut out = vec![]; @@ -1701,6 +1736,7 @@ impl SelectionTool { if p.distance(press) < Self::drag_threshold(cx) { return out; } + let gather = if by_anchor { MoveSnap::by_anchor } else { MoveSnap::new }; // A Shift-drag from a highlighted object of the Global Edit selects it and moves it // with the selection: its snap targets are gathered for the selection with it. self.moving = Some(match exclude { @@ -1708,9 +1744,9 @@ impl SelectionTool { out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 }))); let mut selection = cx.selection.clone(); selection.add(top); - MoveSnap::new(&ToolContext { selection: &selection, ..*cx }) + gather(&ToolContext { selection: &selection, ..*cx }) } - None => MoveSnap::new(cx), + None => gather(cx), }); out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); } @@ -1721,7 +1757,7 @@ impl SelectionTool { } // Shift has already constrained `d`: any change is a snap. self.snapped = (d - unsnapped).hypot() > 1e-9; - self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None, exclude: None }; + self.state = State::Moving { start, press, hold, by_anchor, began: true, deselect: None, key: None, exclude: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); let mut params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }); if backquote { diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 63ca697c7d..b7af316571 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -171,6 +171,94 @@ fn a_move_grabbed_by_an_anchor_lands_it_on_another_anchor() { assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y); } +/// A document with the rectangle of [`doc_with_rect`] (100..200 square) and a straight path from +/// `a` to `b` for each pair, and the rectangle's id. +fn doc_with_rect_and_lines(lines: &[(Point, Point)]) -> (vectorcraft_doc::Document, NodeId) { + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + for &(a, b) in lines { + let n = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[a, b], false)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(n, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + } + (d, id) +} + +/// The rectangle of `d` grabbed at `(gx, gy)` (hovered first, so a press near an anchor grabs it) +/// and dragged to `to`, with Smart Guides on or off (`smart`): the point it was grabbed at after the +/// move, and the label shown. +fn grabbed_and_dragged(d: &vectorcraft_doc::Document, id: NodeId, smart: bool, (gx, gy): (f64, f64), to: (f64, f64)) -> (Point, Option) { + let (none, mut picked, p) = (Selection::default(), Selection::default(), paint()); + picked.add(id); + let (before, after) = (ToolContext { smart_guides: smart, ..cx(d, &none, &p) }, ToolContext { smart_guides: smart, ..cx(d, &picked, &p) }); + let mut t = SelectionTool::default(); + t.pointer(&before, &ev(PointerKind::Move, gx, gy)); + t.pointer(&before, &ev(PointerKind::Down, gx, gy)); + let a = t.pointer(&after, &ev(PointerKind::Drag, to.0, to.1)); + let Some(Action::Preview(_, v)) = a.iter().find(|a| matches!(a, Action::Preview(..))) else { panic!("{a:?}") }; + let start = SelectionTool::hover_point(&before, Point::new(gx, gy)).map_or(Point::new(gx, gy), |(q, _)| q); + let label = t.overlays(&after).iter().find_map(|o| match o { + Overlay::Label { text, .. } => Some(text.clone()), + _ => None, + }); + (start + Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), label) +} + +/// #7411: grabbed by an anchor and dropped near a point along another path, away from its anchors +/// and center, the anchor lands on the path ("path") with Smart Guides. A selection grabbed +/// elsewhere, or with Smart Guides off, does not land on the path. +#[test] +fn a_move_grabbed_by_an_anchor_lands_it_on_another_path() { + let (a, b) = (Point::new(300.0, 400.0), Point::new(500.0, 300.0)); + let (d, id) = doc_with_rect_and_lines(&[(a, b)]); + let off_line = |q: Point| ((b - a).cross(q - a) / (b - a).hypot()).abs(); + // Grab the rectangle by its bottom-right corner (200, 200) and drop it half a point beside + // the line, at (430, 335) on it. + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (430.0, 335.5)); + assert!(off_line(corner) < 1e-9, "the corner lands at {corner:?}, {} off the line", off_line(corner)); + assert!((corner.x - 430.0).abs() < 0.5, "{corner:?}"); + assert_eq!(label.as_deref(), Some("path")); + // Grabbed by its fill instead, the pointer stays half a point beside the line. + let (grip, _) = grabbed_and_dragged(&d, id, true, (150.0, 170.0), (430.0, 335.5)); + assert!(off_line(grip) > 0.4, "the grip lands at {grip:?}"); + // With Smart Guides off, the corner stays where it was dragged. + let (corner, _) = grabbed_and_dragged(&d, id, false, (201.0, 201.0), (430.0, 335.5)); + assert!(off_line(corner) > 0.4, "the corner lands at {corner:?}"); +} + +/// #7411: a selection grabbed by an anchor lands it where two other paths cross ("intersect"), and +/// on another object's anchor near the path rather than on the path. +#[test] +fn a_move_grabbed_by_an_anchor_lands_it_where_paths_cross() { + let (d, id) = + doc_with_rect_and_lines(&[(Point::new(300.0, 400.0), Point::new(500.0, 300.0)), (Point::new(460.0, 250.0), Point::new(460.0, 420.0))]); + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (460.4, 320.5)); + assert!(corner.distance(Point::new(460.0, 320.0)) < 1e-9, "the corner lands at {corner:?}"); + assert_eq!(label.as_deref(), Some("intersect")); + // The end of the upright path (460, 250) is an anchor: the corner lands on it. + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (460.5, 251.0)); + assert!(corner.distance(Point::new(460.0, 250.0)) < 1e-9, "the corner lands at {corner:?}"); + assert_eq!(label.as_deref(), Some("anchor")); +} + +/// #7411: a selection grabbed by an anchor lands it where another path crosses a ruler guide +/// ("intersect"), and a ruler guide running past does not pull it off a path it landed on. +#[test] +fn a_move_grabbed_by_an_anchor_lands_it_where_a_path_crosses_a_ruler_guide() { + let (a, b) = (Point::new(300.0, 400.0), Point::new(500.0, 300.0)); + let (mut d, id) = doc_with_rect_and_lines(&[(a, b)]); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 440.0)); + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (440.5, 330.5)); + assert!(corner.distance(Point::new(440.0, 330.0)) < 1e-9, "the corner lands at {corner:?}"); + assert_eq!(label.as_deref(), Some("intersect")); + // Beyond reach of the crossing, the corner stays on the path with the guide 3.7 points beside + // it. + let off_line = |q: Point| ((b - a).cross(q - a) / (b - a).hypot()).abs(); + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (436.43, 332.12)); + assert!(off_line(corner) < 1e-9, "the corner lands at {corner:?}"); + assert_eq!(label.as_deref(), Some("path")); +} + /// #3716: a press a few pixels from an anchor grabs the object by that anchor, but the drag /// threshold counts from where the pointer went down. A click that wobbles by a pixel selects /// the object without moving it, instead of jumping the anchor onto the pointer. 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/raster.rs b/crates/trace/src/raster.rs index 5e198e6fb7..51312c685a 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -48,6 +48,54 @@ pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { (nw, nh) } +/// The `w × h` RGBA8 `rgba` reduced to `nw × nh` (at most `w × h`): each pixel covers a block of +/// whole source pixels, and every source pixel falls in exactly one block. A pixel's alpha is the +/// mean of its block's alphas, and its color is the block's colors weighted by their alpha, so a +/// clear pixel next to an opaque one does not darken or tint it. A wholly clear block (every alpha +/// 0) keeps the plain mean of its colors. +pub(crate) fn box_reduced(rgba: &[u8], w: u32, h: u32, nw: u32, nh: u32) -> Vec { + let span = |i: u32, n: u32, of: u32| { + let at = |i: u32| usize::try_from(u64::from(i) * u64::from(of) / u64::from(n.max(1))).unwrap_or(usize::MAX); + let start = at(i); + start..at(i + 1).max(start + 1) + }; + let cols: Vec<_> = (0..nw).map(|x| span(x, nw, w)).collect(); + let mut out = Vec::with_capacity(nw as usize * nh as usize * 4); + // Per output column: the alpha-weighted color sums, the alpha sum and the plain color sums. + let mut sums = vec![[0u64; 7]; cols.len()]; + let row_len = w as usize * 4; + for ty in 0..nh { + let rows = span(ty, nh, h); + sums.fill([0; 7]); + for y in rows.clone() { + let Some(row) = rgba.get(y * row_len..(y + 1) * row_len) else { break }; + for (s, c) in sums.iter_mut().zip(&cols) { + let px = row.get(c.start * 4..c.end * 4).unwrap_or_default(); + for &[r, g, b, a] in px.as_chunks::<4>().0 { + let a64 = u64::from(a); + s[0] += u64::from(r) * a64; + s[1] += u64::from(g) * a64; + s[2] += u64::from(b) * a64; + s[3] += a64; + s[4] += u64::from(r); + s[5] += u64::from(g); + s[6] += u64::from(b); + } + } + } + for (s, c) in sums.iter().zip(&cols) { + let count = (rows.len() * c.len()).max(1) as u64; + let mean = |v: u64, n: u64| u8::try_from((v + n / 2) / n).unwrap_or(u8::MAX); + if s[3] == 0 { + out.extend_from_slice(&[mean(s[4], count), mean(s[5], count), mean(s[6], count), 0]); + } else { + out.extend_from_slice(&[mean(s[0], s[3]), mean(s[1], s[3]), mean(s[2], s[3]), mean(s[3], count)]); + } + } + } + out +} + /// An RGBA8 raster (row-major, top row first). #[derive(Clone, Debug, PartialEq)] pub struct Raster { @@ -150,12 +198,14 @@ impl Raster { let mut limits = image::Limits::default(); limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); reader.limits(limits); - let img = reader.decode().map_err(decode_err)?.into_rgba8(); + Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS)) + } + /// `img` scaled down, aspect kept, to the largest size of at most `max` pixels. + pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { let (w, h) = img.dimensions(); - let (nw, nh) = fit_size(w, h, MAX_PIXELS); + let (nw, nh) = fit_size(w, h, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - let small = image::imageops::thumbnail(&img, nw, nh); - Ok(Self { width: nw, height: nh, rgba: small.into_raw() }) + Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec { diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 3b4e84989a..80eb322bbc 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1085,6 +1085,30 @@ fn decode_to_fit_scales_an_oversized_image_down() { assert!(matches!(Raster::decode_to_fit(&png_header(9000, 9000)), Err(TraceError::Decode(_)))); } +/// Scaling an oversized image down weighs each color by its alpha: a clear pixel next to an +/// opaque one leaves the opaque color as it is and only lowers the alpha. +#[test] +fn scaling_down_weighs_color_by_alpha() { + use crate::raster::box_reduced; + const RED: [u8; 4] = [200, 30, 30, 255]; + // Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit` + // does: each scaled pixel covers a red and a clear one. + let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] }); + let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8(); + let fitted = Raster::fitted(&decoded, 2); + assert_eq!((fitted.width, fitted.height), (2, 1)); + assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat()); + // A partly clear blue pulls an opaque red toward it by its alpha. + let mixed = Raster::from_fn(2, 1, |x, _| if x == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 85] }); + assert_eq!(box_reduced(&mixed.rgba, 2, 1, 1, 1), [191, 0, 64, 170]); + // A wholly clear block stays clear, with the plain mean of its colors. + let clear = Raster::from_fn(2, 1, |x, _| [100 * x as u8, 0, 0, 0]); + assert_eq!(box_reduced(&clear.rgba, 2, 1, 1, 1), [50, 0, 0, 0]); + // A 3 → 2 reduction counts every source pixel once. + let ramp = Raster::from_fn(3, 3, |x, y| [(x * 60 + y * 10) as u8, 0, 0, 255]); + assert_eq!(box_reduced(&ramp.rgba, 3, 3, 2, 2), [0, 0, 0, 255, 90, 0, 0, 255, 15, 0, 0, 255, 105, 0, 0, 255]); +} + /// Four flat colours with anti-aliased (blended) edges between the quadrants. fn flat_quads() -> Raster { const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]]; @@ -2206,6 +2230,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/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 50c7b1be30..6d40499106 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -457,5 +457,6 @@ impl VectorcraftApp { /// Show a transient status message. pub fn status(&mut self, s: impl Into) { self.ui.status = s.into(); + self.ui.status_sets = self.ui.status_sets.wrapping_add(1); } } 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/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index c870110434..b49b50881a 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -207,8 +207,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none())); items.push((tl!("Connect"), "dc-join", "path.join", none())); items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none())); - items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); - items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); + items.push((tl!("Convert Selected Anchor Points to Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); + items.push((tl!("Convert Selected Anchor Points to Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); } // As in the reference app, open paths offer Join and Edit Path, compound paths and clipping // masks their Release, images selected with vector art Make Clipping Mask, type converts @@ -257,14 +257,17 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } else if is_image { // Offset Path and Simplify don't apply to an image. } else if !anchors && direct { - // Corner and Smooth convert every anchor of a path selected whole; Remove and Cut - // need anchors picked out, so they wait for them. + // The two convert buttons convert every anchor of a path selected whole; Remove and Cut + // need anchors picked out, so they wait for them. Simplify and Smooth open their + // sliders, as Object › Path › Simplify… and Smooth… do; the page above names Smooth as + // adjusting the path's curvature, apart from converting its anchor points. if open_path { items.push((tl!("Connect"), "dc-join", "path.join", none())); } - items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); - items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); + items.push((tl!("Convert Selected Anchor Points to Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); + items.push((tl!("Convert Selected Anchor Points to Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); items.push((tl!("Simplify"), "spline", "object.path.simplify", none())); + items.push((tl!("Smooth"), "dc-smooth", "object.path.smooth", none())); } else if !anchors { if open_path { items.push((tl!("Join"), "dc-join", "path.join", none())); @@ -292,7 +295,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { .into_iter() .filter_map(|(shown, chip)| shown.then_some(chip)) .collect(); - let widths: Vec = items.iter().map(|(label, ..)| action_width(ui, label)).collect(); + let widths: Vec = items.iter().map(|(label, _, cmd, _)| if icon_only(cmd) { BAR_MORE } else { action_width(ui, label) }).collect(); let font_w = fonts.then(|| { let w = crate::panels::character::TASK_BAR_WIDTHS; w.iter().sum::() + BAR_GAP * (w.len() - 1) as f32 @@ -367,7 +370,17 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } for ((label, icon, cmd, params), at) in items.iter().zip(&layout.actions) { - if *at == Some(row) && action_button(ui, &t, label, icon) { + if *at != Some(row) { + continue; + } + // The convert buttons show their icon alone, named by their hover text, as + // the Control bar's do. + let clicked = if icon_only(cmd) { + widgets::icon_button(ui, icon, label, false, BAR_MORE).clicked() + } else { + action_button(ui, &t, label, icon) + }; + if clicked { run = Some((*cmd, params.clone())); } } @@ -459,7 +472,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})), Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app), Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app), - // Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do. + // The convert buttons convert the direct-selected anchors, as the Control bar's buttons do. Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})), Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})), Some((c, p)) => crate::menus::invoke(app, c, p), @@ -664,6 +677,11 @@ fn paint_or_object_chip( } } +/// Whether the bar's item that runs `cmd` shows its icon alone: the anchor convert buttons. +fn icon_only(cmd: &str) -> bool { + matches!(cmd, TASK_BAR_TO_CORNER | TASK_BAR_TO_SMOOTH) +} + /// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or /// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints. pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) { @@ -714,8 +732,11 @@ const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards"; /// The Contextual Task Bar's Rearrange All button with the Artboard tool. const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards"; -/// The Contextual Task Bar's Corner button for direct-selected anchors. +/// The Contextual Task Bar's Convert Selected Anchor Points to Corner button, for direct-selected +/// anchors or a path selected whole with Direct Selection. const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner"; -/// The Contextual Task Bar's Smooth button for direct-selected anchors. +/// The Contextual Task Bar's Convert Selected Anchor Points to Smooth button, for direct-selected +/// anchors or a path selected whole with Direct Selection. The bar's Smooth button runs +/// `object.path.smooth` instead. const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth"; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4d609f2b5e..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}; @@ -10447,9 +10514,34 @@ fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() { assert_eq!(boards(&app), before, "one undo step"); } +/// The names and places of the buttons the Contextual Task Bar draws, from the accessibility tree +/// (`ctx` has it enabled): an icon-only button is named by its hover text. +fn task_bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> { + frame(app, ctx, vec![]); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let Some(bar) = task_bar_rect(ctx) else { return vec![] }; + let Some(update) = out.platform_output.accesskit_update.as_ref() else { return vec![] }; + let mut v: Vec<(String, egui::Rect)> = update + .nodes + .iter() + .filter(|(_, n)| n.role() == egui::accesskit::Role::Button) + .filter_map(|(_, n)| { + let b = n.bounds()?; + let r = egui::Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32)); + let label = n.label()?; + bar.contains(r.center()).then(|| (label.to_string(), r)) + }) + .collect(); + v.sort_by(|a, b| a.1.left().total_cmp(&b.1.left())); + v +} + /// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect, -/// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens -/// the path there and Connect closes it again, as the Control bar's anchor buttons do. +/// Cut Path and the two convert buttons, which show their icons alone. Converting to smooth and to +/// corner changes the selected anchor, Cut Path opens the path there and Connect closes it again, +/// as the Control bar's anchor buttons do. #[test] fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -10458,10 +10550,21 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { app.select_tool("directSelection"); app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); let ctx = egui::Context::default(); + ctx.enable_accesskit(); let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); - assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]); + assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Duplicate"]); + let buttons = task_bar_buttons(&mut app, &ctx).into_iter().map(|(t, _)| t).collect::>(); + let expected = [ + "Remove Anchor Points", + "Connect", + "Cut Path", + "Convert Selected Anchor Points to Corner", + "Convert Selected Anchor Points to Smooth", + "Duplicate", + ]; + assert_eq!(buttons[..expected.len()], expected, "{buttons:?}"); let press = |app: &mut VectorcraftApp, label: &str| { - let labels = task_bar_labels(app, &ctx); + let labels = task_bar_buttons(app, &ctx); let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); @@ -10471,11 +10574,11 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { let subpath = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone(); let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone(); - press(&mut app, "Smooth"); + press(&mut app, "Convert Selected Anchor Points to Smooth"); let a = subpath(&app).anchors[1]; assert!(a.has_in() && a.has_out(), "smooth: {a:?}"); assert_eq!(last_undo(&app), "Convert Anchor Points"); - press(&mut app, "Corner"); + press(&mut app, "Convert Selected Anchor Points to Corner"); let a = subpath(&app).anchors[1]; assert!(!a.has_in() && !a.has_out(), "corner: {a:?}"); press(&mut app, "Cut Path"); 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/cursors.rs b/crates/ui-egui/src/cursors.rs index a438a71881..5495d3753c 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -677,18 +677,16 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(3.0, HALO)); ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(1.2, INK)); } - // The Pencil's crosshair with a short straight line where the closing ring goes. + // The Pencil's crosshair with a short level line where the closing ring goes. Cursor::PencilStraight => { crosshair(ink, p); - line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0)); + line(ink, p + vec2(3.55, 8.5), p + vec2(13.45, 8.5)); } - // The straight-line cursor's line leaving a filled square: the end point a line continues from. + // The Pencil's crosshair with a short line rising to the right where the closing ring goes: + // the stroke carries on from the path. Cursor::PencilContinue => { crosshair(ink, p); - // The square first, so its halo does not cover the line. - let end = p + vec2(5.0, 12.0); - poly(ink, [(-2.0, -2.0), (2.0, -2.0), (2.0, 2.0), (-2.0, 2.0)].map(|(x, y)| end + vec2(x, y)).to_vec(), INK, INK); - line(ink, end, p + vec2(12.0, 5.0)); + line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0)); } Cursor::Eyedropper => eyedropper(ink, p), Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)), diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs index ae1947616f..49c932175d 100644 --- a/crates/ui-egui/src/cursors/tests.rs +++ b/crates/ui-egui/src/cursors/tests.rs @@ -244,7 +244,7 @@ fn the_pencil_new_path_cursor_is_the_crosshair_with_a_star() { } } -/// The Pencil's straight-line cursor is the crosshair with a short straight line below right, +/// The Pencil's straight-line cursor is the crosshair with a short level line below right, /// through the point where the closing cursor's ring is hollow. #[test] fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() { @@ -264,15 +264,18 @@ fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() { assert_eq!(at(&cross, 8.5, 8.5)[3], 0, "the crosshair has no badge at {ppp}"); // Along the line, not across it: no ink off its ends. assert!(!inked(at(&img, 5.0, 5.0)) && !inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); + // Level: inked at both ends of it, and not where the continuation cursor's line rises. + assert!(inked(at(&img, 4.5, 8.5)) && inked(at(&img, 12.5, 8.5)), "a level line at {ppp}"); + assert!(!inked(at(&img, 6.0, 11.0)) && !inked(at(&img, 11.0, 6.0)), "not a rising one at {ppp}"); assert_ne!(img, ring, "at {ppp}"); assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); } } -/// The Pencil's continuation cursor is the straight-line cursor with a filled square on the -/// line's lower end, beside the line where the straight-line cursor has no ink. +/// The Pencil's continuation cursor is the crosshair with a short line rising to the right below +/// right, where the straight-line cursor's line is level. #[test] -fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point() { +fn the_pencil_continuation_cursor_is_the_crosshair_with_a_rising_line() { for ppp in [1.0, 2.0] { let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); let img = rasterize(Cursor::PencilContinue, ppp).unwrap(); @@ -285,8 +288,10 @@ fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point( }; let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}"); - assert!(inked(at(&img, 3.5, 13.5)), "the end point's square at {ppp}"); - assert!(!inked(at(&straight, 3.5, 13.5)), "the straight-line cursor has no end point at {ppp}"); + assert!(inked(at(&img, 5.0, 11.0)) && inked(at(&img, 10.0, 6.0)), "the line rises to the right at {ppp}"); + assert!(!inked(at(&straight, 6.0, 11.0)), "the straight-line cursor's line is level at {ppp}"); + assert!(!inked(at(&img, 4.5, 8.5)) && !inked(at(&img, 12.5, 8.5)), "not a level one at {ppp}"); + assert!(!inked(at(&img, 3.5, 13.5)), "no end point square at {ppp}"); assert!(!inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d9d89e0bb9..5afad21b0d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -153,7 +153,7 @@ pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices}; -pub use export_as::open as open_export_as; +pub use export_as::{export_selection, open as open_export_as}; pub(crate) use export_for_screens::drop_unread_screen_settings; pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ @@ -947,6 +947,8 @@ mod tests_accessibility; #[cfg(test)] mod tests_export; #[cfg(test)] +mod tests_export_selection; +#[cfg(test)] mod tests_import_pdf; #[cfg(test)] mod tests_package; diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index f34363e5b7..e807df7b01 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -26,9 +26,10 @@ const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"]; /// The on, off or unset checkboxes. const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman"]; /// The attributes of Advanced Character Formats, in the order the section shows them. -const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "language"]; +const ADVANCED: &[&str] = + &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume", "language"]; /// The number fields of Advanced Character Formats. -const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight"]; +const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume"]; /// The Insert Aki Left and Right fields, which take the Character panel's presets and stop at /// [`vectorcraft_doc::text::MAX_AKI`]. const AKI: [&str; 2] = ["akiLeft", "akiRight"]; @@ -86,6 +87,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "rotation", "akiLeft", "akiRight", + "tsume", "language", "fill", "fill_tint", @@ -275,6 +277,7 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { "rotation" => tl!("Character Rotation"), "akiLeft" => tl!("Insert Aki Left"), "akiRight" => tl!("Insert Aki Right"), + "tsume" => tl!("Tsume"), "fill_tint" => tl!("Fill Tint"), "stroke_tint" => tl!("Stroke Tint"), "stroke_width" => tl!("Weight"), @@ -322,6 +325,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { // Insert Aki stops at 0 and at the largest value the style takes, as the Character // panel's fields do. let n = if AKI.contains(&key) { n.clamp(0.0, vectorcraft_doc::text::MAX_AKI) } else { n }; + // Tsume stops at 0% and 100% the same way. + let n = if key == "tsume" { n.clamp(0.0, vectorcraft_doc::text::MAX_TSUME) } else { n }; Ok(Some(json!(n))) } } @@ -600,7 +605,7 @@ pub(crate) fn reset_basic(d: &mut Dialog) { } } -/// Reset Panel in Advanced Character Formats: its nine attributes become unset. +/// Reset Panel in Advanced Character Formats: its ten attributes become unset. pub(crate) fn reset_advanced(d: &mut Dialog) { d.fields.insert("__resetAdvanced".into(), json!(true)); for key in ADVANCED_NUMBERS { @@ -755,8 +760,8 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { } /// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, the Tate-chu-yoko -/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right (with the Character -/// panel's presets) and Language, each empty (unset) or set. +/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right and Tsume (with the +/// Character panel's presets) and Language, each empty (unset) or set. pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("character-style-advanced").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { for (key, caption, unit) in [ @@ -787,6 +792,17 @@ pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) { } ui.end_row(); } + let presets: Vec = crate::panels::character::TSUME_PRESETS.iter().map(f64::to_string).collect(); + let presets: Vec<&str> = presets.iter().map(String::as_str).collect(); + let row = ui.label(tl!("Tsume")); + let value = if d.fields.get("tsume").is_none_or(Value::is_null) { String::new() } else { d.str("tsume") }; + ui.horizontal(|ui| { + if let Some(v) = ui.label_inputs_by(row.id, |ui| crate::widgets::text_presets(ui, (KIND, "tsume"), &value, &presets, 160.0)) { + d.fields.insert("tsume".into(), json!(v)); + } + ui.label("%"); + }); + ui.end_row(); let row = ui.label(tl!("Language:")); let names: Vec<&str> = std::iter::once(" ").chain(vectorcraft_doc::setup::LANGUAGES.iter().map(|(name, _, _)| *name)).collect(); let current = d.fields.get("language").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1847,6 +1863,50 @@ mod tests { assert!(t.runs.iter().all(|r| (r.style.aki_left, r.style.aki_right) == (250.0, 1000.0))); } + /// Tsume in Advanced Character Formats: empty is unset, an explicit 0 stays set, a typed value + /// stops at 0 and 100, Reset Panel unsets it, and the field is drawn and commits to the style + /// and the text set in it. + #[test] + fn tsume_field_shows_sets_resets_and_commits() { + let d = dialog_for(json!({"size": 12.0})); + assert_eq!(d.fields["tsume"], json!("")); + let mut d = dialog_for(json!({"tsume": 50.0})); + assert_eq!(d.fields["tsume"], json!(50.0)); + for (typed, kept) in [("0", 0.0), ("25.5", 25.5), ("150", 100.0), ("-5", 0.0)] { + d.fields.insert("tsume".into(), json!(typed)); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tsume": kept})), "{typed}"); + } + d.fields.insert("tsume".into(), json!("")); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({}))); + d.fields.insert("tsume".into(), json!("tight")); + assert_eq!(edited_attrs(&d).unwrap_err(), "Tsume must be a number"); + let mut d = dialog_for(json!({"tsume": 50.0, "size": 12.0})); + d.fields.insert("section".into(), json!("advanced")); + reset_section(&mut d); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"size": 12.0}))); + + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 900.0)); + let mut d = app.ui.dialog.clone().unwrap(); + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| advanced_body(ui, &mut d)); + let texts = texts_of(&out); + out.textures_delta.clear(); + assert!(texts.iter().any(|(text, _)| text == "Tsume"), "Tsume is drawn: {texts:?}"); + app.ui.dialog.as_mut().unwrap().fields.insert("tsume".into(), json!("75")); + super::super::confirm(&mut app).unwrap(); + let state = app.session.doc().unwrap(); + assert_eq!(state.doc.char_styles[0].attrs, attrs(json!({"size": 24.0, "tsume": 75.0}))); + let vectorcraft_doc::NodeKind::Text(t) = &state.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.runs.iter().all(|r| r.style.tsume == 75.0)); + } + /// The negated tags of the twenty stylistic sets: an explicit empty Stylistic Sets choice. fn no_sets() -> Vec { vectorcraft_text::EXTRA_FEATURES[..20].iter().map(|s| format!("-{s}")).collect() diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index 498d854fe9..2e0aefc54c 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -158,8 +158,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); ui.add_space(4.0); + // Hidden for Export Selection, which writes the selected art alone. let selection = app.session.active().is_some_and(|st| !st.selection.is_empty()); - if widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) { + if !d.bool(super::export_as::SELECTION_FIELD) + && widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) + { d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly"))); } check(ui, d, "alterPaths", tl!("Alter Paths for Appearance")); @@ -227,5 +230,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dxf_options = Value::Object(remembered.into_iter().flatten().collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); - io::export(app, Some("dxf"), path, ¶ms) + io::export(app, Some("dxf"), path, &super::export_as::exported(d, params)) } diff --git a/crates/ui-egui/src/dialogs/eps_options.rs b/crates/ui-egui/src/dialogs/eps_options.rs index 3a20a6799b..8d34706df4 100644 --- a/crates/ui-egui/src/dialogs/eps_options.rs +++ b/crates/ui-egui/src/dialogs/eps_options.rs @@ -30,7 +30,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { }; /// Fields that are not remembered between exports (they belong to one export). -const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; +const PER_EXPORT: [&str; 8] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly", super::export_as::SELECTION_FIELD]; /// The options a first EPS Options dialog starts from (the export's defaults). fn defaults() -> Map { @@ -133,5 +133,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); - io::export(app, Some("eps"), path, ¶ms) + io::export(app, Some("eps"), path, &super::export_as::exported(d, params)) } diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index bd9ee6ca49..add0fbed73 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -4,8 +4,10 @@ //! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG //! Options, Text Export Options, DXF Options, EPS Options, and Save PDF), //! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of -//! installed exporter plug-ins come after the built-in ones: they write the whole document, and one -//! with parameters asks for them before writing (`plugin::open_export`). +//! installed exporter plug-ins come after the built-in ones: they write the whole document (from +//! Export Selection, the selection), and one with parameters asks for them before writing +//! (`plugin::open_export`). Export Selection ([`export_selection`]) takes the same route after its +//! save panel, and every format then writes the selection alone, cropped to it. use std::sync::LazyLock; @@ -144,12 +146,52 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dialog = None; let path = io::export_path(app, None, f)?; let range = if all { "all".to_string() } else { range }; - let r = options_or_write(app, f, plugin, path, use_artboards, all, range); + let r = options_or_write(app, f, plugin, path, Pages { use_artboards, all, range, selection: false }); // The options follow the save panel: the Enter that closed it isn't theirs (as in Save As). super::opened_by_panel(app); r } +/// The options dialogs' field that is true when Export Selection opened them: OK then writes the +/// selection alone ([`exported`]). Not remembered with the dialog's options. +pub(super) const SELECTION_FIELD: &str = "__selection"; + +/// `params` of an options dialog `d` for [`io::export`]: with [`io::EXPORT_SELECTION`] when +/// Export Selection opened `d`. +pub(super) fn exported(d: &Dialog, mut params: Value) -> Value { + if d.bool(SELECTION_FIELD) + && let Some(o) = params.as_object_mut() + { + o.insert(io::EXPORT_SELECTION.into(), json!(true)); + } + params +} + +/// File › Export Selection (`document.exportSelection` without params): the save panel picks the +/// file and its format, then the format's options dialog follows as in Export As, else the file is +/// written. Each route writes the selected objects alone, cropped to them. Nothing selected to +/// export stops before the save panel. +pub fn export_selection(app: &mut VectorcraftApp) -> Result { + let st = app.session.active().ok_or("no document")?; + fileio::selection_doc(st, "document.exportSelection", false).map_err(|e| e.to_string())?; + let path = io::selection_path(app)?; + let f = fileio::writable_format(None, Some(&path))?; + let plugin = vectorcraft_plugins::export::by_format(f.id); + let pages = Pages { use_artboards: false, all: true, range: String::new(), selection: true }; + let r = options_or_write(app, f, plugin, path, pages); + super::opened_by_panel(app); + r +} + +/// What an export writes: with `use_artboards` the artboards (`all`, else the 1-based `range`), +/// else the bounds of the visible art, or with `selection` the selection alone. +struct Pages { + use_artboards: bool, + all: bool, + range: String, + selection: bool, +} + /// After the save panel picked `path`: the options dialog of format `f` (or of its exporter /// plug-in), else the file written. fn options_or_write( @@ -157,11 +199,10 @@ fn options_or_write( f: &'static Format, plugin: Option>, path: String, - use_artboards: bool, - all: bool, - range: String, + pages: Pages, ) -> Result { - match f.id { + let Pages { use_artboards, all, range, selection } = pages; + let r = match f.id { "svg" | "svgz" => { svg_options::open(app, svg_options::Mode::Export, Some(&path)); if let Some(o) = app.ui.dialog.as_mut() { @@ -169,24 +210,33 @@ fn options_or_write( } Ok(Value::Null) } - // Save PDF writes the artboards as pages, or one page of the art's bounds. + // Save PDF writes the artboards as pages, or one page of the art's bounds or the selection. "pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })), "pdf" => save_pdf::open(app, &json!({ "path": path, "useArtboards": false })), "txt" => { text_export::open(app, &path); + if let Some(o) = app.ui.dialog.as_mut().filter(|_| selection) { + o.fields.insert("selectionOnly".into(), json!(true)); + } Ok(Value::Null) } _ => { let mut params = json!({ "format": f.id, "path": path }); // A format that holds the whole document (a template) has no Use Artboards: the // export is not given the option, which its warnings would name as not used. - if plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards") { + if !selection && (plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards")) { params["useArtboards"] = json!(use_artboards); if use_artboards { params["range"] = json!(range); } } - if let Some(plugin) = plugin.filter(|p| !p.manifest().params.is_empty()) { + let plugin = plugin.filter(|p| !p.manifest().params.is_empty()); + let dialog = plugin.is_none() && (f.raster || matches!(f.id, "dxf" | "eps")); + if selection { + // An options dialog keeps the mark as a field of its own, which OK hands on. + params[if dialog { SELECTION_FIELD } else { io::EXPORT_SELECTION }] = json!(true); + } + if let Some(plugin) = plugin { plugin::open_export(app, &plugin, params); return Ok(Value::Null); } @@ -202,7 +252,12 @@ fn options_or_write( eps_options::open(app, ¶ms); return Ok(Value::Null); } - io::export(app, Some(f.id), Some(path), ¶ms) + return io::export(app, Some(f.id), Some(path), ¶ms); } + }; + // Export Selection's mark on the options dialog that opened. + if let Some(o) = app.ui.dialog.as_mut().filter(|_| selection) { + o.fields.insert(SELECTION_FIELD.into(), json!(true)); } + r } diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 16d22d4430..ecfb0c303e 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; @@ -147,7 +149,10 @@ pub(crate) fn grid_from_text(text: &str) -> Grid { } /// A text file's bytes as text: UTF-16 with its byte-order mark (spreadsheets' "Unicode Text"), -/// else UTF-8 without one. +/// else UTF-16 without one when the bytes hold NUL bytes and Import Data Sets' rule gives them a +/// byte order ([`vectorcraft_engine::cmd::utf16_order_without_mark`]), else UTF-8 with or without +/// one. UTF-16 is read as it is after a byte-order mark: an odd last byte is left out, and an +/// unpaired surrogate reads as U+FFFD. pub(crate) fn decode(bytes: &[u8]) -> String { let utf16 = |rest: &[u8], le: bool| { let units: Vec = rest.as_chunks::<2>().0.iter().map(|p| if le { u16::from_le_bytes(*p) } else { u16::from_be_bytes(*p) }).collect(); @@ -157,7 +162,10 @@ pub(crate) fn decode(bytes: &[u8]) -> String { [0xFF, 0xFE, rest @ ..] => utf16(rest, true), [0xFE, 0xFF, rest @ ..] => utf16(rest, false), [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), - _ => String::from_utf8_lossy(bytes).into_owned(), + _ => match bytes.contains(&0).then(|| vectorcraft_engine::cmd::utf16_order_without_mark(bytes)).flatten() { + Some(le) => utf16(bytes, le), + None => String::from_utf8_lossy(bytes).into_owned(), + }, } } @@ -332,9 +340,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(()) } @@ -517,6 +532,28 @@ mod tests { assert_eq!(grid_from_text(&wide)[0].len(), MAX_COLS); } + #[test] + fn utf16_without_a_byte_order_mark_reads_by_its_line_breaks() { + let text = "\tSales é\r\nQ1\t1\n"; + let le: Vec = text.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let be: Vec = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + assert_eq!(decode(&le), text, "little-endian"); + assert_eq!(decode(&be), text, "big-endian"); + assert_eq!(grid_from_text(&decode(&le)), g(&[&["", "Sales é"], &["Q1", "1"]])); + // One row has no line break: the side of each unit that holds the NUL byte gives the order. + for (bytes, order) in [(b"\0Q\x001\0\t\x002".to_vec(), "big-endian"), (b"Q\x001\0\t\x002\0".to_vec(), "little-endian")] { + assert_eq!(decode(&bytes), "Q1\t2", "one line, {order}"); + } + // A file cut short by one byte, or with an unpaired surrogate, reads as it would after a + // byte-order mark. + assert_eq!(decode(&[le.as_slice(), &[0x0A]].concat()), text, "odd length"); + assert_eq!(decode(&[le.as_slice(), &[0x00, 0xD8]].concat()), format!("{text}\u{FFFD}"), "unpaired surrogate"); + // A line break in both byte orders leaves the order unknown, and UTF-8 with a stray NUL + // byte isn't UTF-16: both read as UTF-8, as before. + assert_eq!(decode(&[0x0A, 0, 0, 0x0A]), "\n\0\0\n"); + assert_eq!(decode(b"a,b\n1,2\0"), "a,b\n1,2\0"); + } + #[test] fn a_sheet_sent_wider_and_taller_than_a_graph_is_cut_to_one() { // One long row and one long column: their product would be ten billion cells. @@ -527,6 +564,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/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index dc42dcff87..168f5cfda1 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -9,8 +9,10 @@ //! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`, //! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading` //! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`, -//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on) and, -//! after Select All in threaded text, `__story` (the edited frame, whose story it acts on). +//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on; with +//! the Selection tool, every frame of each story selected when the dialog opened, locked and hidden +//! frames included) and, after Select All in threaded text, `__story` (the edited frame, whose +//! story it acts on). //! //! Behaviour reference (public documentation): //! . @@ -95,6 +97,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result { targets = json!({"ids": [id.0], "start": a, "end": b}); // After Select All in threaded text: every paragraph of every frame ([`params`]). story = crate::panels::character::text_story(app).map(|_| id.0); + } else { + // With the Selection tool: every frame of each selected story. + crate::panels::character::target_selected_stories(app, &mut targets); } let mut values = Map::new(); for (_, prefix, _) in ROWS { @@ -423,6 +428,74 @@ mod tests { } } + /// A threaded story of three frames, its second frame locked and its third hidden, a type object + /// of its own, and the first frame selected with the Selection tool → the app, the frames and + /// the other type object. + fn story_selected_in_its_first_frame() -> (VectorcraftApp, [u64; 3], u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + (app, [a, b, c], other) + } + + /// Every paragraph's Auto Leading in `id`. + fn auto_leadings(app: &VectorcraftApp, id: u64) -> Vec { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justification.auto_leading).collect(), + _ => panic!("not text"), + } + } + + /// With the Selection tool a selected frame of threaded text stands for its whole story: the + /// dialog opens blank where a locked or hidden frame differs, previews on every frame, Cancel + /// restores them, and OK changes every frame of the story as it was when the dialog opened, in + /// one undo step, leaving other type and a selection made later alone. + #[test] + fn selection_tool_justification_reaches_every_frame_of_the_story() { + let (mut app, frames, other) = story_selected_in_its_first_frame(); + let [a, _, c] = frames; + for id in frames { + assert!(!auto_leadings(&app, id).is_empty(), "frame {id} holds text"); + } + app.run("text.setFormat", json!({"ids": [c], "autoLeading": 150})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("ui.justification", json!({})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("autoLeading"), "the hidden frame differs"); + set(&mut app, "autoLeading", json!(200)); + set(&mut app, "preview", json!(true)); + frame(&mut app); + for id in frames { + assert!(auto_leadings(&app, id).iter().all(|l| *l == 200.0), "the preview shows on frame {id}"); + } + super::super::cancel(&mut app); + assert!(auto_leadings(&app, a).iter().all(|l| *l == 120.0)); + assert!(auto_leadings(&app, c).iter().all(|l| *l == 150.0), "Cancel restores the hidden frame"); + + app.run("ui.justification", json!({})).unwrap(); + // A selection made while the dialog is open does not change what it acts on. + app.run("select.set", json!({"ids": [other]})).unwrap(); + set(&mut app, "autoLeading", json!(140)); + let undo = app.session.active().unwrap().history.undo.len(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + for id in frames { + assert!(auto_leadings(&app, id).iter().all(|l| *l == 140.0), "frame {id} takes the change"); + } + assert!(auto_leadings(&app, other).iter().all(|l| *l == 120.0), "other type keeps its paragraphs"); + app.run("edit.undo", json!({})).unwrap(); + assert!(auto_leadings(&app, c).iter().all(|l| *l == 150.0), "undo restores the hidden frame"); + } + /// When paragraphs in scope disagree, only shared justification values are filled in. #[test] fn justification_dialog_mixed_paragraph_fields_show_blank() { 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/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 04257bc64d..98d6225c17 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -53,7 +53,11 @@ fn format_of(d: &Dialog) -> Option<&'static Format> { /// Open the options of raster format `f` for an export whose params (format, path, artboard /// choice) are `params`. pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) { - let size = app.session.active().and_then(|st| export_size(&st.doc, ¶ms)); + let selection = params[super::export_as::SELECTION_FIELD] == true; + let size = app.session.active().and_then(|st| match selection { + true => vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false).ok().map(|(_, r)| (r.width(), r.height())), + false => export_size(&st.doc, ¶ms), + }); if let Some(o) = params.as_object_mut() { o.insert("ppi".into(), json!(72)); o.extend(defaults(app, f)); @@ -340,5 +344,5 @@ pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (format, path) = (d.str("format"), d.str("path")); app.ui.dialog = None; - io::export(app, Some(&format), Some(path), &form::params(d)) + io::export(app, Some(&format), Some(path), &super::export_as::exported(d, form::params(d))) } diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 2e19375994..225c2cee52 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -6,7 +6,8 @@ //! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is //! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`). //! Opened from Export As with Use Artboards off (`__artBounds`), the dialog has no Artboards -//! section, and OK writes one page of the visible art's bounds through [`io::export`]. +//! section, and OK writes one page of the visible art's bounds through [`io::export`]; opened +//! from Export Selection (`__selection` too), the page is the selection's crop. //! //! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and //! `description` instead of the preset and artboards, and OK saves the preset. @@ -154,7 +155,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } let r = match SaveMode::of(&d.str("__save")) { Some(mode) => io::save_pdf(app, mode, params(d)), - None if d.bool(ART_BOUNDS) => export_art(app, params(d)), + None if d.bool(ART_BOUNDS) => export_art(app, d), None => io::export_pdf(app, params(d)), }; // A question the write asked (replace the file the document was opened from?) stays open. @@ -164,12 +165,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } -/// A page of the art's bounds (Export As with Use Artboards off): written as Export As writes -/// other formats ([`io::export`]), with the dialog's options and a saved preset written out. -fn export_art(app: &mut VectorcraftApp, mut p: Value) -> Result { +/// A page of the art's bounds (Export As with Use Artboards off), or of the selection's crop +/// (Export Selection): written as Export As writes other formats ([`io::export`]), with the +/// dialog's options and a saved preset written out. +fn export_art(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = params(d); let path = p.as_object_mut().and_then(|o| o.remove("path")).and_then(|v| v.as_str().map(str::to_string)); let p = pdf::expand_preset(&app.session, "file.exportAs", &p).map_err(|e| e.to_string())?.into_owned(); - io::export(app, Some("pdf"), path, &p) + io::export(app, Some("pdf"), path, &super::export_as::exported(d, p)) } /// The settings as `pdf.preset.save` params (without the artboards and path). diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index ac568dbace..b477bb35c0 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -129,15 +129,23 @@ fn options(d: &Dialog) -> Value { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let opts = options(d); - if Mode::of(d) == Mode::Export && d.bool("useArtboards") { + let selection = d.bool(super::export_as::SELECTION_FIELD); + if Mode::of(d) == Mode::Export && d.bool("useArtboards") && !selection { let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); let pick: vectorcraft_engine::cmd::fileio::ArtboardPick = serde_json::from_value(opts.clone()).map_err(|e| e.to_string())?; pick.resolve(n)?; } - // Remembered for next time, without the document's artboard range. + // Remembered for next time, without the document's artboard range. Export Selection shows no + // Use Artboards, so the one remembered before stays. let mut last = opts.clone(); if let Some(o) = last.as_object_mut() { o.remove("range"); + if selection { + match app.ui.svg_options.get("useArtboards") { + Some(v) => o.insert("useArtboards".into(), v.clone()), + None => o.remove("useArtboards"), + }; + } } app.ui.svg_options = last; app.ui.dialog = None; @@ -145,6 +153,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(p) = d.fields.get("path").filter(|p| p.is_string()) { params["path"] = p.clone(); } + let params = super::export_as::exported(d, params); let id = match Mode::of(d) { Mode::Export => "file.export.svg", Mode::Save => "file.saveAs", @@ -177,8 +186,11 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: boo fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { option_fields(ui, d, false); + // Export Selection writes the selection's crop, without artboards to choose. if Mode::of(d) == Mode::Export { - artboards(app, ui, d); + if !d.bool(super::export_as::SELECTION_FIELD) { + artboards(app, ui, d); + } } else { check(ui, d, "preserveEditing", tl!("Preserve Editing Capabilities"), true); } diff --git a/crates/ui-egui/src/dialogs/tests_export_selection.rs b/crates/ui-egui/src/dialogs/tests_export_selection.rs new file mode 100644 index 0000000000..15fe559974 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_export_selection.rs @@ -0,0 +1,238 @@ +//! Export Selection (`document.exportSelection` without params): the picked format's options +//! dialog follows the save panel, as in Export As, and writes the selection alone. + +use std::cell::Cell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_plugins::wat::{self as tpl, Echo}; + +use super::tests_export::{Written, app_saving, frame, kind, names, set}; +use super::*; + +/// The last file written, as text. +fn last_text(written: &Written) -> String { + String::from_utf8_lossy(&written.borrow().last().unwrap().1).into_owned() +} + +/// The width and height in a PNG's header. +fn png_size(png: &[u8]) -> (u32, u32) { + let n = |at: usize| u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]); + (n(16), n(20)) +} + +/// Pick `path` in the next save panels. +fn picking(app: &mut VectorcraftApp, path: &str) { + let picked = path.to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); +} + +/// How many paths a native document's JSON (or the input an exporter plug-in is given) holds. +fn paths_in(bytes: &[u8]) -> usize { + String::from_utf8_lossy(vectorcraft_format::json_part(bytes)).matches("\"anchors\"").count() +} + +/// A PDF continues in Save PDF, without its artboard choice, and its options apply: the page is +/// the selected 10 m rectangle's crop (not the art's bounds, which take in the ellipse), with a +/// `UserUnit` once "Write pages longer than 200 in at full size in points" is turned off. +#[test] +fn export_selection_to_pdf_continues_in_save_pdf() { + let (mut app, written) = app_saving(2, |_| "/out/sel.pdf".into()); + app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("savePdf"), "the PDF options follow"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("path"), "/out/sel.pdf"); + assert!(d.bool("__artBounds") && d.bool(export_as::SELECTION_FIELD)); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + set(&mut app, "longPagesInPoints", json!(false)); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/sel.pdf"); + assert!(app.ui.dialog.is_none()); + let bytes = written.borrow()[0].1.clone(); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit"), "the dialog's option applies"); + let pdf = vectorcraft_pdf::import(&bytes).unwrap(); + assert_eq!(pdf.artboards.len(), 1); + let page = pdf.artboards[0].rect; + assert!((28_346.0..28_350.0).contains(&page.width()), "{}", page.width()); + assert_eq!(page.height().round(), 50.0, "the selection's crop"); +} + +/// Every format with an options dialog shows it after the save panel, and OK writes the +/// selection alone, cropped to the selected 10×5 pt rectangle: the PNG at 72 ppi, the SVG's view +/// box, the EPS bounding box and the DXF's extents. The mark is not kept with the options +/// remembered for the next export. +#[test] +fn export_selection_continues_in_each_format_s_options() { + let (mut app, written) = app_saving(2, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + for (ext, options) in [("svg", "svgOptions"), ("png", "pngOptions"), ("eps", "epsOptions"), ("dxf", "dxfOptions")] { + let path = format!("/out/sel.{ext}"); + picking(&mut app, &path); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some(options), "{ext}"); + assert!(app.ui.dialog.as_ref().unwrap().bool(export_as::SELECTION_FIELD), "{ext}"); + if ext == "png" { + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__size"], json!([10.0, 5.0]), "the selection's size"); + } + frame(&mut app); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "{ext}"); + assert_eq!(names(&written).last(), Some(&path), "{ext}"); + let text = last_text(&written); + match ext { + "png" => assert_eq!(png_size(&written.borrow().last().unwrap().1), (10, 5), "10×5 pt at 72 ppi"), + "svg" => assert!(text.contains(r#"viewBox="0 0 10 5""#), "{text}"), + "eps" => assert!(text.contains("%%BoundingBox: 0 0 10 5"), "{text}"), + _ => { + let at = text.find("$EXTMAX").unwrap_or_else(|| panic!("no extents: {text}")); + let max: Vec = text[at..].lines().skip(1).collect::>().chunks(2).take(2).map(|c| c[1].trim().parse().unwrap()).collect(); + // In millimeters, DXF Options' default unit: 10×5 pt. + assert!((max[0] - 10.0 * 25.4 / 72.0).abs() < 1e-3 && (max[1] - 5.0 * 25.4 / 72.0).abs() < 1e-3, "the selection's extents: {max:?}"); + } + } + } + assert_eq!(names(&written).len(), 4, "one file each"); + assert!(app.ui.eps_options.get(export_as::SELECTION_FIELD).is_none(), "EPS Options does not remember the mark"); + assert!(app.ui.svg_options.get(export_as::SELECTION_FIELD).is_none()); +} + +/// Text Export Options opens with Selection Only on, and OK writes the selected type alone. +#[test] +fn export_selection_to_text_writes_the_selected_type() { + let (mut app, written) = app_saving(1, |_| "/out/sel.txt".into()); + app.run("text.create", json!({"x": 10, "y": 10, "text": "Left out"})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 30, "text": "Selected"})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("txtOptions")); + assert!(app.ui.dialog.as_ref().unwrap().bool("selectionOnly")); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/out/sel.txt"]); + assert_eq!(last_text(&written), "Selected\n"); +} + +/// SVG Options opened by Export Selection hides Use Artboards, and its OK leaves the Use +/// Artboards setting that the next Export As SVG starts from as it was. +#[test] +fn export_selection_to_svg_keeps_the_remembered_use_artboards() { + let (mut app, _) = app_saving(2, |_| "/out/sel.svg".into()); + app.run("file.export.svg", json!({})).unwrap(); + set(&mut app, "useArtboards", json!(true)); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.svg_options["useArtboards"], true); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("svgOptions")); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.svg_options["useArtboards"], true, "{}", app.ui.svg_options); +} + +/// A format without an options dialog (a native document) and an exporter plug-in, with or +/// without parameters, are given the selection alone: fewer paths than the whole document. +#[test] +fn export_selection_without_a_dialog_and_to_plug_ins_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("file.exportAs", json!({"path": "/out/all.vectorcraft"})).unwrap(); + let all = paths_in(&written.borrow().last().unwrap().1); + picking(&mut app, "/out/sel.vectorcraft"); + app.run("document.exportSelection", json!({})).unwrap(); + assert!(app.ui.dialog.is_none(), "written at once"); + assert_eq!(names(&written).last().map(String::as_str), Some("/out/sel.vectorcraft")); + let selected = paths_in(&written.borrow().last().unwrap().1); + assert!(selected >= 1 && selected < all, "{selected} of {all} paths"); + + for (id, ext, params) in + [("test.ui.selwhole", "tsw", "{}"), ("test.ui.selparams", "tsp", r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#)] + { + let wasm = wat::parse_str(tpl::echo_exporter(&tpl::exporter(id, &format!(r#"["{ext}"]"#), params), Echo::Input)).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap(); + picking(&mut app, &format!("/out/sel.{ext}")); + app.run("document.exportSelection", json!({})).unwrap(); + if params != "{}" { + assert_eq!(kind(&app), Some(plugin::EXPORT_KIND), "{id} asks for its parameters"); + frame(&mut app); + confirm(&mut app).unwrap(); + } + assert!(app.ui.dialog.is_none(), "{id}"); + assert_eq!(names(&written).last(), Some(&format!("/out/sel.{ext}"))); + let given = paths_in(&written.borrow().last().unwrap().1); + app.run("plugin.remove", json!({"id": id})).unwrap(); + assert_eq!(given, selected, "{id} is given the selection"); + } +} + +/// The question before replacing the file the document was opened from runs the export again as +/// an Export Selection: the PNG written after OK is the selection's. +#[test] +fn export_selection_after_the_question_before_losing_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| "/out/sel.png".into()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.imported_from = Some("/out/sel.png".into()); + st.import_losses = vec!["hidden type".into()]; + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("pngOptions")); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["pending"], super::confirm::KIND); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/out/sel.png"]); + assert_eq!(png_size(&written.borrow()[0].1), (10, 5), "the selection's crop"); +} + +/// `file.exportAs {path, exportSelection: true}` writes the selection without a dialog; a value +/// that is not true or false is refused. +#[test] +fn export_as_with_export_selection_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("file.exportAs", json!({"path": "/out/sel.png", "exportSelection": true, "useArtboards": true})).unwrap(); + assert_eq!(png_size(&written.borrow()[0].1), (10, 5)); + let e = app.run("file.exportAs", json!({"path": "/out/sel.png", "exportSelection": "yes"})).unwrap_err(); + assert!(e.contains("exportSelection must be true or false"), "{e}"); + assert_eq!(names(&written).len(), 1); +} + +/// Appearance of Black › Printing / Exporting set to rich black draws a CMYK black as RGB black in +/// a PNG from Export As and from Export Selection, as `document.export` draws it. +#[test] +fn rich_black_reaches_files_from_the_export_dialogs() { + let (mut app, written) = app_saving(1, |_| "/out/sel.png".into()); + app.run("file.new", json!({"width": 40, "height": 30, "colorMode": "cmyk"})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + app.run("paint.setFill", json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); + let px = |w: &Written| { + let w = w.borrow(); + image::load_from_memory(&w.last().unwrap().1).unwrap().to_rgba8().get_pixel(10, 10).0 + }; + app.run("file.exportAs", json!({"path": "/out/all.png"})).unwrap(); + let accurate = px(&written); + assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}"); + app.session.prefs.black_output = "rich".into(); + app.run("file.exportAs", json!({"path": "/out/all.png"})).unwrap(); + assert_eq!(px(&written), [0, 0, 0, 255], "Export As"); + app.run("document.exportSelection", json!({})).unwrap(); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(names(&written).last().map(String::as_str), Some("/out/sel.png")); + assert_eq!(px(&written), [0, 0, 0, 255], "Export Selection"); +} + +/// With nothing selected, Export Selection stops before the save panel. +#[test] +fn nothing_selected_stops_before_the_save_panel() { + let (mut app, written) = app_saving(1, |_| "/out/sel.pdf".into()); + app.run("select.none", json!({})).unwrap(); + let asked = Rc::new(Cell::new(false)); + let a = asked.clone(); + app.services.pick_save = Some(Box::new(move |_| { + a.set(true); + Some("/out/sel.pdf".into()) + })); + let e = app.run("document.exportSelection", json!({})).unwrap_err(); + assert!(e.contains("select something to export"), "{e}"); + assert!(!asked.get() && app.ui.dialog.is_none() && written.borrow().is_empty()); +} diff --git a/crates/ui-egui/src/dialogs/text_export.rs b/crates/ui-egui/src/dialogs/text_export.rs index 6701fd65d7..12e7b5d425 100644 --- a/crates/ui-egui/src/dialogs/text_export.rs +++ b/crates/ui-egui/src/dialogs/text_export.rs @@ -1,5 +1,6 @@ -//! Export As → Text: the Text Export Options (encoding, line endings, selection only) for a -//! picked `.txt` path. Fields are `document.export` params (`format`, `path`…). +//! Export As → Text and Export Selection to `.txt`: the Text Export Options (encoding, line +//! endings, selection only) for a picked `.txt` path. Fields are `document.export` params +//! (`format`, `path`…). use serde_json::{Value, json}; @@ -39,14 +40,25 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); }); ui.add_space(8.0); - let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + // Export Selection keeps Selection Only on. + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()) && !d.bool(super::export_as::SELECTION_FIELD); ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", tl!("Selection Only"))); false } -/// Write the text file (the command runs first, so a bad option keeps the dialog open). +/// Write the text file (the command runs first, so a bad option keeps the dialog open). Opened by +/// Export Selection, it writes `document.exportSelection`'s text. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let path = io::save_command_output(app, "document.export", "txt", form::params(d))?; + let mut params = form::params(d); + let id = if d.bool(super::export_as::SELECTION_FIELD) { + if let Some(o) = params.as_object_mut() { + o.remove("selectionOnly"); + } + "document.exportSelection" + } else { + "document.export" + }; + let path = io::save_command_output(app, id, "txt", params)?; app.ui.dialog = None; Ok(json!({ "path": path })) } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index cad5b53d8e..8db6de2f96 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -698,7 +698,7 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { if depth <= 1 || quoted(v) { v.to_string() } else if v.starts_with(['{', '[']) { - lang.catalog().exact_message(v).unwrap_or(v).to_string() + exact_translation(lang.catalog(), v).unwrap_or_else(|| v.to_string()) } else { message_at(lang, v, depth - 1) } @@ -708,6 +708,12 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { fill_once(template, &args) } +/// The translation of a message with an exact `@msg` row in `catalog`, its `{{` and `}}` shown as +/// `{` and `}`. +fn exact_translation(catalog: &Catalog, s: &str) -> Option { + catalog.exact_message(s).map(|tr| fill_once(tr, &[])) +} + /// Fill `{name}` placeholders in one pass: a value that holds `{name}` text stays as it is. /// Unknown placeholders are left as written. `{{` and `}}` are a literal `{` and `}`. pub(crate) fn fill_once(template: &str, args: &[(&str, &str)]) -> String { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f0ab11fc4a..28944173dd 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2588,6 +2588,7 @@ font-technology Variable Променливи Proportional Metrics Пропорционални метрики Insert Aki Left Вмъкване на аки отляво Insert Aki Right Вмъкване на аки отдясно + Tsume Цуме Character Direction Посока на знаците Digits Цифри Arabic Арабски @@ -6864,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 f73b5a407f..01b90e5148 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1573,6 +1573,7 @@ lighting Light Svjetlo Proportional Metrics Proporcionalna metrika Insert Aki Left Umetni aki lijevo Insert Aki Right Umetni aki desno + Tsume Tsume Character Direction Smjer znakova Digits Cifre Arabic Arapski @@ -6490,7 +6491,7 @@ anchor Points: Tačke: Stop on Errors Zaustavi pri greškama Yonroku-ban Yonroku-ban @msg image '{key}' has no pixels and was left out slika '{key}' nema piksela i izostavljena je -@msg not an RTF file: it doesn't start with {\\rtf nije RTF datoteka: ne počinje s {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf nije RTF datoteka: ne počinje s {{\\rtf @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG ovo je PostScript datoteka (.ai sačuvan u starijem formatu ili bez PDF kompatibilnosti, ili EPS datoteka), koja se još ne može otvoriti: sačuvajte je kao PDF, .ai kompatibilan s PDF-om ili SVG font-script Japanese Japansko swatch [None] [Nijedna] @@ -6917,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 ddfed44ad0..4ddd1d5605 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3358,6 +3358,7 @@ digits Malayalam Malaiàlam Proportional Metrics Mètriques proporcionals Insert Aki Left Insereix aki a l'esquerra Insert Aki Right Insereix aki a la dreta + Tsume Tsume Proportional Lining Xifres proporcionals alineades Position: Posició: Default Position Posició predeterminada @@ -5875,6 +5876,7 @@ digits Malayalam Malaiàlam @msg no action set to save no hi ha cap conjunt d’accions per desar @msg the action set file can't be read in full el fitxer de conjunt d’accions no es pot llegir sencer @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos +@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}` S’ha carregat “{name}”: {n} pas té una ordre desconeguda i s’ha conservat: “{action}”, pas {step}, `{command}`|S’ha carregat “{name}”: {n} passos tenen ordres desconegudes i s’han conservat; el primer: “{action}”, pas {step}, `{command}` @msg not an action set file no és un fitxer de conjunt d’accions @msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referència superaria els {MAX_BYTES} bytes @@ -5933,7 +5935,7 @@ digits Malayalam Malaiàlam @msg not an HP-GL file no és un fitxer HP-GL @msg not a PICT file no és un fitxer PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb -@msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf no és un fitxer RTF: no comença amb {{\\rtf @msg not found no s’ha trobat @msg not in pattern editing mode no esteu en mode d’edició de motius @msg finish editing the pattern first acabeu primer l’edició del motiu @@ -6366,6 +6368,7 @@ digits Malayalam Malaiàlam @msg truncated truncat @msg Unembedded to {_1} S’ha desincrustat a {_1} @msg unknown command `{0}` ordre desconeguda `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` el pas {step} de “{action}” executa l’ordre desconeguda `{command}` @msg unknown command `{0}`; did you mean `{1}`? ordre desconeguda `{0}`; volies dir `{1}`? @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode control desconegut `{name}`: fillStroke, coloring, drawMode o screenMode @msg Unknown dimension mode Mode de dimensió desconegut @@ -6498,8 +6501,8 @@ digits Malayalam Malaiàlam @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un exportador declara d’1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` com a màxim {MAX_UNGROUPED} paràmetres fora de grups: doneu un `group` als altres @msg can't encode the input no es pot codificar l’entrada -@msg expected {"dataBase64"} s’esperava {"dataBase64"} -@msg expected {"objects"} s’esperava {"objects"} +@msg expected {{"dataBase64": "…"}} s’esperava {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} s’esperava {{"objects": [...]}} @msg export `{name}`: {e} exportació del mòdul `{name}`: {e} @msg extension {e} is listed twice l’extensió {e} apareix dues vegades @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensió {e}: feu servir 1-16 lletres minúscules i dígits, sense el punt @@ -6563,11 +6566,11 @@ digits Malayalam Malaiàlam @msg a painted live effect returns only `objects` un efecte dinàmic de pintura només retorna `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una peça d’un efecte dinàmic de pintura només té `path`, `fillRule`, `fills` i `strokes` @msg a read-only object was left out s’ha omès un objecte de només lectura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {"id"} sol o tal com s’ha rebut +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {{"id": …}} sol o tal com s’ha rebut @msg a string una cadena @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un traç només té `paint`, `width`, `join`, `cap` i `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un traç ha de ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un efecte ha de ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un traç ha de ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un efecte ha de ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un efecte en un farciment només retorna `fills` @msg an effect on a stroke returns only `strokes` un efecte en un traç només retorna `strokes` @msg an object must be a JSON object un objecte ha de ser un objecte JSON @@ -7450,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 @@ -7479,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/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index ab3abff6f5..f2fab1fcfa 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -13,7 +13,8 @@ pub struct Catalog { ids: HashMap, /// plural messages: `one|other` → forms plurals: HashMap>, - /// status and error messages without placeholders (`@msg`): English → translation + /// status and error messages without placeholders (`@msg`): English, its `{{` and `}}` read as + /// `{` and `}` → translation, as written messages: HashMap, /// status and error messages with `{name}` placeholders (`@msg`, and each English form of a /// `@msgplural` row), most specific first @@ -193,13 +194,20 @@ impl Catalog { "@plural" => { c.plurals.insert(src, tr.split('|').map(str::to_string).collect()); } - "@msg" => { - if src.contains('{') || src.contains("}}") { - c.templates.extend(Template::parse(&src, vec![tr], false)); - } else { - c.messages.insert(src, tr); - } + "@msg" if !src.contains(['{', '}']) => { + c.messages.insert(src, tr); } + "@msg" => match Template::parse(&src, vec![tr], false) { + // A row without placeholders is an exact message, under its text with `{{` and + // `}}` read as literal braces. + Some(Template { pieces, mut forms, .. }) if matches!(pieces.as_slice(), [Piece::Text(_)]) => { + if let (Some(Piece::Text(text)), Some(tr)) = (pieces.into_iter().next(), forms.pop()) { + c.messages.insert(text, tr); + } + } + Some(template) => c.templates.push(template), + None => {} + }, "@msgplural" => { let forms: Vec = tr.split('|').map(str::to_string).collect(); c.templates.extend(src.split('|').filter_map(|english| Template::parse(english, forms.clone(), true))); @@ -249,8 +257,8 @@ impl Catalog { self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.forms.as_slice(), caps))) } - /// The translation of a status or error message that has an exact `@msg` entry; templates are - /// not tried. + /// The translation of a status or error message that has an exact `@msg` entry, as written in + /// the catalog (`{{` and `}}` for literal braces); templates are not tried. pub fn exact_message(&self, s: &str) -> Option<&str> { self.messages.get(s).map(String::as_str) } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3b46d0600a..211982f086 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1601,6 +1601,7 @@ lighting Light Světlo Proportional Metrics Proporcionální metrika Insert Aki Left Vložit aki vlevo Insert Aki Right Vložit aki vpravo + Tsume Tsume Character Direction Směr znaků Digits Číslice Arabic Arabské @@ -5054,6 +5055,7 @@ imageTrace Outline Obrys @msg no action set to save žádná sada akcí k uložení @msg the action set file can't be read in full soubor sady akcí nelze přečíst celý @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků +@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}` Načteno „{name}“: {n} krok má neznámý příkaz a byl ponechán: „{action}“, krok {step}, `{command}`|Načteno „{name}“: {n} kroky mají neznámé příkazy a byly ponechány; první: „{action}“, krok {step}, `{command}`|Načteno „{name}“: {n} kroků má neznámé příkazy a bylo ponecháno; první: „{action}“, krok {step}, `{command}` @msg not an action set file není to soubor sady akcí @msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: reference by byla větší než {MAX_BYTES} bajtů @@ -5294,6 +5296,7 @@ imageTrace Outline Obrys @msg tool shortcuts must be a single key (optionally with Shift) zkratky nástrojů musí být jediná klávesa (volitelně se Shift) @msg truncated zkráceno @msg unknown command `{0}` neznámý příkaz `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` krok {step} akce „{action}“ spouští neznámý příkaz `{command}` @msg unknown command `{0}`; did you mean `{1}`? neznámý příkaz `{0}`; mysleli jste `{1}`? @msg unknown effect `{id}` neznámý efekt `{id}` @msg unknown format neznámý formát @@ -6393,8 +6396,8 @@ axis Y Axis osa Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` exportní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` mimo skupiny smí být nejvýše {MAX_UNGROUPED} parametrů: ostatním přiřaďte `group` @msg can't encode the input vstup nelze zakódovat -@msg expected {"dataBase64"} očekáváno {"dataBase64"} -@msg expected {"objects"} očekáváno {"objects"} +@msg expected {{"dataBase64": "…"}} očekáváno {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} očekáváno {{"objects": [...]}} @msg export `{name}`: {e} export `{name}`: {e} @msg extension {e} is listed twice přípona {e} je uvedena dvakrát @msg extension {e}: use 1-16 lower-case letters and digits, without the dot přípona {e}: použijte 1-16 malých písmen a číslic, bez tečky @@ -6458,11 +6461,11 @@ axis Y Axis osa Y @msg a painted live effect returns only `objects` malující živý efekt vrací jen `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` část malujícího živého efektu má jen `path`, `fillRule`, `fills` a `strokes` @msg a read-only object was left out objekt jen pro čtení byl vynechán -@msg a read-only object was left out: return it, as {"id"} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {"id"} nebo tak, jak byl předán +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {{"id": …}} nebo tak, jak byl předán @msg a string řetězec @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` tah má jen `paint`, `width`, `join`, `cap` a `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} tah musí být {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} efekt musí být {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} tah musí být {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} efekt musí být {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` efekt na výplni vrací jen `fills` @msg an effect on a stroke returns only `strokes` efekt na tahu vrací jen `strokes` @msg an object must be a JSON object objekt musí být objekt JSON @@ -7442,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 @@ -7469,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 81b6195bdd..b3de0ae89a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1509,6 +1509,7 @@ lighting Light Lys Proportional Metrics Proportionale metrikker Insert Aki Left Indsæt aki til venstre Insert Aki Right Indsæt aki til højre + Tsume Tsume Character Direction Tegnretning Digits Cifre Arabic Arabisk @@ -6879,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 8f6bc1aeeb..d0610144ef 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2632,6 +2632,7 @@ mark-style Japanese Japanisch Proportional Metrics Proportionale Metrik Insert Aki Left Aki links einfügen Insert Aki Right Aki rechts einfügen + Tsume Tsume Character Direction Zeichenrichtung Digits Ziffern Arabic Arabisch @@ -4668,6 +4669,7 @@ theme Appearance Erscheinungsbild @msg no action set to save kein Aktionssatz zum Speichern @msg the action set file can't be read in full die Aktionssatzdatei kann nicht vollständig gelesen werden @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen +@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}“ geladen: {n} Schritt hat einen unbekannten Befehl und wurde behalten: „{action}“, Schritt {step}, `{command}`|„{name}“ geladen: {n} Schritte haben unbekannte Befehle und wurden behalten; der erste: „{action}“, Schritt {step}, `{command}` @msg not an action set file keine Aktionssatzdatei @msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: die Referenz wäre größer als {MAX_BYTES} Byte @@ -4909,6 +4911,7 @@ theme Appearance Erscheinungsbild @msg tool shortcuts must be a single key (optionally with Shift) Tastaturbefehle für Werkzeuge müssen aus einer einzelnen Taste bestehen (optional mit Shift) @msg truncated abgeschnitten @msg unknown command `{0}` unbekannter Befehl `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` Schritt {step} von „{action}“ führt den unbekannten Befehl `{command}` aus @msg unknown command `{0}`; did you mean `{1}`? unbekannter Befehl `{0}`; meinten Sie `{1}`? @msg unknown effect `{id}` unbekannter Effekt `{id}` @msg unknown format unbekanntes Format @@ -5985,8 +5988,8 @@ axis Y Axis Y-Achse @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ein Export-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` höchstens {MAX_UNGROUPED} Parameter außerhalb von Gruppen: Geben Sie den anderen eine `group` @msg can't encode the input die Eingabe kann nicht codiert werden -@msg expected {"dataBase64"} erwartet: {"dataBase64"} -@msg expected {"objects"} erwartet: {"objects"} +@msg expected {{"dataBase64": "…"}} erwartet: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} erwartet: {{"objects": [...]}} @msg export `{name}`: {e} Modulexport `{name}`: {e} @msg extension {e} is listed twice die Dateiendung {e} ist doppelt aufgeführt @msg extension {e}: use 1-16 lower-case letters and digits, without the dot Dateiendung {e}: Verwenden Sie 1-16 Kleinbuchstaben und Ziffern, ohne Punkt @@ -6050,11 +6053,11 @@ axis Y Axis Y-Achse @msg a painted live effect returns only `objects` ein malender Live-Effekt gibt nur `objects` zurück @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ein Teil eines malenden Live-Effekts hat nur `path`, `fillRule`, `fills` und `strokes` @msg a read-only object was left out ein schreibgeschütztes Objekt wurde weggelassen -@msg a read-only object was left out: return it, as {"id"} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {"id"} allein oder so, wie es übergeben wurde +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {{"id": …}} allein oder so, wie es übergeben wurde @msg a string eine Zeichenkette @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` eine Kontur hat nur `paint`, `width`, `join`, `cap` und `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} eine Kontur muss {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} sein -@msg an effect must be {"id", "params"?, "visible"?} ein Effekt muss {"id", "params"?, "visible"?} sein +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} eine Kontur muss {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} sein +@msg an effect must be {{"id", "params"?, "visible"?}} ein Effekt muss {{"id", "params"?, "visible"?}} sein @msg an effect on a fill returns only `fills` ein Effekt auf einer Fläche gibt nur `fills` zurück @msg an effect on a stroke returns only `strokes` ein Effekt auf einer Kontur gibt nur `strokes` zurück @msg an object must be a JSON object ein Objekt muss ein JSON-Objekt sein @@ -7065,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 @@ -8893,6 +8902,134 @@ breakText Glyphs Glyphen @msg the paths are too many or too tangled to find where they cross die Pfade sind zu zahlreich oder zu verschlungen, um ihre Kreuzungen zu finden @msg the rectangle or line reaches too many paths to check das Rechteck oder die Linie erreicht zu viele Pfade, um sie zu prüfen @msg {name} must be a number {name} muss eine Zahl sein +# Messages no catalog translated yet: image export and color profiles, color books and swatch exchange files, 3D and Revolve, and script and file errors. +@msg 3D effects require finite coordinates within 100 million points 3D-Effekte erfordern endliche Koordinaten innerhalb von 100 Millionen Punkt +@msg 3D effects require one to ten lights 3D-Effekte erfordern ein bis zehn Lichter +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-Farbtiefe {_1}: 1, 4, 8, 16, 24 oder 32 Bit pro Pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP-Kodierung fehlgeschlagen: {width} × {height} Pixel passen nicht zum Format oder zum Pixelpuffer +@msg GIF encoding failed: the palette image is malformed GIF-Kodierung fehlgeschlagen: Das Palettenbild ist fehlerhaft +@msg GIF encoding failed: {e} GIF-Kodierung fehlgeschlagen: {e} +@msg JPEG colour profile: {e} JPEG-Farbprofil: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg JPEG encoding failed: {e} JPEG-Kodierung fehlgeschlagen: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg PNG encoding failed: the palette image is malformed PNG-Kodierung fehlgeschlagen: Das Palettenbild ist fehlerhaft +@msg PNG encoding failed: {e} PNG-Kodierung fehlgeschlagen: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG-Kodierung fehlgeschlagen: {width} × {height} Pixel sind keine gültige Bildgröße +@msg PSD colour profile: {e} PSD-Farbprofil: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg Revolve profile contains invalid coordinates Kreiseln: Das Profil enthält ungültige Koordinaten +@msg Revolve profile is too complex Kreiseln: Das Profil ist zu komplex +@msg Revolve projection is not finite Kreiseln: Die Projektion ist nicht endlich +@msg Revolve requires finite profile coordinates within 100 million points Kreiseln erfordert endliche Profilkoordinaten innerhalb von 100 Millionen Punkt +@msg Revolve surface is too complex; reduce Segments or simplify the profile Kreiseln: Die Oberfläche ist zu komplex; verringern Sie „Segmente“ oder vereinfachen Sie das Profil +@msg Revolve surface is too complex; simplify the profile Kreiseln: Die Oberfläche ist zu komplex; vereinfachen Sie das Profil +@msg Revolve visibility cannot split the projected boundary Kreiseln: Die Sichtbarkeitsberechnung kann die projizierte Begrenzung nicht teilen +@msg Revolve visibility creates too many fragments Kreiseln: Die Sichtbarkeitsberechnung erzeugt zu viele Fragmente +@msg Revolve visibility creates too many vector anchors Kreiseln: Die Sichtbarkeitsberechnung erzeugt zu viele Vektor-Ankerpunkte +@msg Revolve visibility exceeds its geometry work limit Kreiseln: Die Sichtbarkeitsberechnung überschreitet ihre Grenze für den Geometrieaufwand +@msg Revolve visibility exceeds its spatial index limit Kreiseln: Die Sichtbarkeitsberechnung überschreitet ihre Grenze für den räumlichen Index +@msg Revolve visibility has too many boundary crossings Kreiseln: Die Sichtbarkeitsberechnung hat zu viele Kreuzungen der Begrenzung +@msg Revolve visibility has too many input anchors Kreiseln: Die Sichtbarkeitsberechnung hat zu viele Eingabe-Ankerpunkte +@msg Revolve visibility requires convex cap sectors Kreiseln: Die Sichtbarkeitsberechnung erfordert konvexe Sektoren der Deckflächen +@msg Revolve visibility requires finite projected faces Kreiseln: Die Sichtbarkeitsberechnung erfordert endliche projizierte Flächen +@msg SVG options: {e} SVG-Optionen: {e} +@msg Save for Web settings: {e} Einstellungen für „Für Web speichern“: {e} +@msg TIFF colour profile: {e} TIFF-Farbprofil: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg TIFF encoding failed: {e} TIFF-Kodierung fehlgeschlagen: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-Farbtiefe {_1}: 16, 24 oder 32 Bit pro Pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg The 3D projection is not finite Die 3D-Projektion ist nicht endlich +@msg The 3D shadow is too complex; turn Shadows off or simplify the art Der 3D-Schatten ist zu komplex; schalten Sie „Schatten“ aus oder vereinfachen Sie die Grafik +@msg The shape contains invalid coordinates Die Form enthält ungültige Koordinaten +@msg Too many Revolve faces for visibility evaluation Zu viele Kreiseln-Flächen für die Sichtbarkeitsberechnung +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft liest höchstens {ASE_MAX_ENTRIES} Farben und Gruppen aus einer Farbfeld-Austauschdatei, und diese Bibliothek hat {entries} +@msg WebP encoding failed: {e} WebP-Kodierung fehlgeschlagen: {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` ist smallThumb, mediumThumb, largeThumb, smallList oder largeList +@msg a line can't run from a point to itself eine Linie kann nicht von einem Punkt zu sich selbst verlaufen +@msg a line needs at least two points eine Linie braucht mindestens zwei Punkte +@msg a linked JavaScript file is outside the read roots eine verknüpfte JavaScript-Datei liegt außerhalb der Lesestammordner +@msg a number cannot be read eine Zahl kann nicht gelesen werden +@msg a swatch exchange block is too long ein Block der Farbfeld-Austauschdatei ist zu lang +@msg a swatch exchange name is too long ein Name in der Farbfeld-Austauschdatei ist zu lang +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels ein Bild mit {sw} × {sh} Pixeln kann nicht auf {width} × {height} Pixel skaliert werden +@msg a {width} × {height} image is too large ein Bild mit {width} × {height} Pixeln ist zu groß +@msg a {width} × {height} image: {e} ein Bild mit {width} × {height} Pixeln: {e} +@msg an image could not be written: {e} ein Bild konnte nicht geschrieben werden: {e} +@msg an image that couldn't be read was left out ein Bild, das nicht gelesen werden konnte, wurde ausgelassen +@msg can't open “{name}”: {e} „{name}“ kann nicht geöffnet werden: {e} +@msg cannot align zip entries to {align} bytes die ZIP-Einträge können nicht auf {align} Byte ausgerichtet werden +@msg cannot open the automation {kind} root `{_1}`: {e} der Automatisierungs-Stammordner ({kind}) `{_1}` kann nicht geöffnet werden: {e} +@msg cannot read profile: {0} das Profil kann nicht gelesen werden: {0} +@msg color book version {version} isn't supported (only 1) die Farbbuchversion {version} wird nicht unterstützt (nur 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) Farbbücher im Farbraum {other} werden nicht unterstützt (nur RGB, CMYK und Lab) +@msg file name too long to zip: {_1} der Dateiname ist zu lang für ein ZIP-Archiv: {_1} +@msg file name too long to zip: {name} der Dateiname ist zu lang für ein ZIP-Archiv: {name} +@msg freeform line {i}: {e} Freiformlinie {i}: {e} +@msg freeform point {i}: {e} Freiformpunkt {i}: {e} +@msg give `{key}` or `{other}`, not both geben Sie `{key}` oder `{other}` an, nicht beides +@msg grayscale profiles can't be working spaces yet Graustufenprofile können noch keine Arbeitsfarbräume sein +@msg input contains NaN or infinite coordinates die Eingabe enthält NaN oder unendliche Koordinaten +@msg input path could not be closed der Eingabepfad konnte nicht geschlossen werden +@msg invalid ICC profile: {0} ungültiges ICC-Profil: {0} +@msg invalid scale {scale} ungültige Skalierung {scale} +@msg its localized names take more than {_1} KB seine lokalisierten Namen belegen mehr als {_1} KB +@msg its objects are nested too deep ({e}) seine Objekte sind zu tief verschachtelt ({e}) +@msg its tag table lists the `{_1}` tag more than once seine Tag-Tabelle führt das Tag `{_1}` mehr als einmal auf +@msg not a graphic style library: {e} keine Grafikstil-Bibliothek: {e} +@msg not a swatch library (.vcswatches or .gpl) keine Farbfeldbibliothek (.vcswatches oder .gpl) +@msg not a swatch library (format `{_1}`) keine Farbfeldbibliothek (Format `{_1}`) +@msg not a swatch library: {e} keine Farbfeldbibliothek: {e} +@msg not an action set file ({e}) keine Aktionssatzdatei ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray der Farbraum {other} des Profils ist nicht RGB, CMYK oder Graustufen +@msg regular expression too large regulärer Ausdruck zu groß +@msg script file is not UTF-8 text: {value} die Skriptdatei ist kein UTF-8-Text: {value} +@msg stop {i}: {e} Farbstopp {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) die Version {major}.{minor} der Farbfeld-Austauschdatei wird nicht unterstützt (nur 1.x) +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it die Grafik eines Diagrammdesigns in einer Diagrammreihe wird aus dem Design neu gezeichnet: Bearbeiten Sie das Design, um sie zu ändern +@msg the color book holds more than {ASE_MAX_ENTRIES} colors das Farbbuch enthält mehr als {ASE_MAX_ENTRIES} Farben +@msg the color book is cut short das Farbbuch ist unvollständig +@msg the color book's names take more than {_1} MB die Namen des Farbbuchs belegen mehr als {_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 das Bild hätte {w} × {h} Pixel; Rasterexporte sind auf {MAX_RASTER_SIDE} Pixel pro Seite und {_1} Megapixel begrenzt: Verringern Sie die Skalierung oder die Auflösung +@msg the optimised image can't be read back: {e} das optimierte Bild kann nicht zurückgelesen werden: {e} +@msg the pixel buffer doesn't match the image size der Pixelpuffer passt nicht zur Bildgröße +@msg the preview: {e} die Vorschau: {e} +@msg the request may not read the New Document profiles folder die Anfrage darf den Ordner der Profile für neue Dokumente nicht lesen +@msg the shapes are too degenerate to combine die Formen sind zu entartet, um sie zu kombinieren +@msg the shapes cross themselves and each other too often to combine die Formen überschneiden sich selbst und einander zu oft, um sie zu kombinieren +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups die Farbfeld-Austauschdatei enthält mehr als {ASE_MAX_ENTRIES} Farben und Gruppen +@msg the swatch exchange file is cut short die Farbfeld-Austauschdatei ist unvollständig +@msg the {_1} plate can't be read: {e} der Farbauszug {_1} kann nicht gelesen werden: {e} +@msg this binary DXF file is damaged or cut short diese binäre DXF-Datei ist beschädigt oder unvollständig +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that dies ist eine DWG-Zeichnung, die nicht gelesen werden kann: Speichern Sie sie in der CAD-App als DXF und öffnen Sie diese Datei +@msg too many classes zu viele Zeichenklassen +@msg too many groups zu viele Gruppen +@msg too many layers for PSD (at most 32767) zu viele Ebenen für PSD (höchstens 32767) +@msg too many swatch exchange blocks zu viele Blöcke in der Farbfeld-Austauschdatei +@msg unknown colour profile: {0} unbekanntes Farbprofil: {0} +@msg {_1} clashes with a built-in profile name {_1} kollidiert mit dem Namen eines integrierten Profils +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} Pixel ist zu groß für GIF (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung +@msg {_1}: no usable transform to/from sRGB {_1}: keine verwendbare Transformation von/nach sRGB +@msg {name} is not a CMYK profile {name} ist kein CMYK-Profil +@msg {name} is not an RGB profile {name} ist kein RGB-Profil +@msg {path}: could not be read {path}: konnte nicht gelesen werden +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} Pixel ist zu groß für JPEG (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} Pixel ist zu groß für Targa (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} Pixel ist zu groß für PSD (höchstens {MAX_SIDE} Pixel pro Seite): Verringern Sie die Auflösung +# Messages other catalogs translate and German did not: Banner Hems and Grommets and recovery data. +@msg `{key}` must be from 0.01 to 1000 pt `{key}` muss zwischen 0,01 und 1000 pt liegen +@msg select the banner, or mark every artboard wählen Sie das Banner aus oder markieren Sie alle Zeichenflächen +@msg the banner has no width or height das Banner hat keine Breite oder Höhe +@msg the grommet inset is too large for the banner der Randabstand der Ösen ist zu groß für das Banner +@msg the hem is too deep for the banner der Saum ist zu tief für das Banner +@msg the spacing makes more than {MAX_GROMMETS} grommets der Abstand ergibt mehr als {MAX_GROMMETS} Ösen +@msg there is no banner to mark es gibt kein Banner zum Markieren +@msg turn on at least one side aktivieren Sie mindestens eine Seite +@msg with no hem and no grommets there is nothing to mark ohne Saum und ohne Ösen gibt es nichts zu markieren +@msg {saving} failed: {e} {saving} fehlgeschlagen: {e} Banner Hems and Grommets… Bannersäume und Ösen… Banner Hems and Grommets Bannersäume und Ösen Hem Saum diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7b61589c18..d03f8c6958 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3428,6 +3428,7 @@ theme Appearance Εμφάνιση Proportional Metrics Αναλογικές μετρικές Insert Aki Left Εισαγωγή aki αριστερά Insert Aki Right Εισαγωγή aki δεξιά + Tsume Tsume Arabic Αραβικά Hindi Χίντι Farsi Περσικά @@ -4700,6 +4701,7 @@ digits Malayalam Μαλαγιάλαμ @msg no action set to save δεν υπάρχει σύνολο ενεργειών για αποθήκευση @msg the action set file can't be read in full το αρχείο συνόλου ενεργειών δεν μπορεί να διαβαστεί ολόκληρο @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα +@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}»: {n} βήμα έχει άγνωστη εντολή και διατηρήθηκε: «{action}», βήμα {step}, `{command}`|Φορτώθηκε το «{name}»: {n} βήματα έχουν άγνωστες εντολές και διατηρήθηκαν· το πρώτο: «{action}», βήμα {step}, `{command}` @msg not an action set file δεν είναι αρχείο συνόλου ενεργειών @msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: η αναφορά θα ήταν μεγαλύτερη από {MAX_BYTES} byte @@ -4934,6 +4936,7 @@ digits Malayalam Μαλαγιάλαμ @msg tool shortcuts must be a single key (optionally with Shift) οι συντομεύσεις εργαλείων πρέπει να περιλαμβάνουν ένα πλήκτρο (προαιρετικά με Shift) @msg truncated περικομμένο @msg unknown command `{0}` άγνωστη εντολή `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` το βήμα {step} της ενέργειας «{action}» εκτελεί την άγνωστη εντολή `{command}` @msg unknown command `{0}`; did you mean `{1}`? άγνωστη εντολή `{0}`· μήπως εννοείτε `{1}`; @msg unknown effect `{id}` άγνωστο εφέ `{id}` @msg unknown format άγνωστη μορφή @@ -6345,8 +6348,8 @@ axis Y Axis Άξονας Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εξαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` το πολύ {MAX_UNGROUPED} παράμετροι εκτός ομάδων: δώστε στις υπόλοιπες ένα `group` @msg can't encode the input η είσοδος δεν μπορεί να κωδικοποιηθεί -@msg expected {"dataBase64"} αναμενόταν {"dataBase64"} -@msg expected {"objects"} αναμενόταν {"objects"} +@msg expected {{"dataBase64": "…"}} αναμενόταν {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} αναμενόταν {{"objects": [...]}} @msg export `{name}`: {e} εξαγωγή `{name}`: {e} @msg extension {e} is listed twice η επέκταση {e} αναφέρεται δύο φορές @msg extension {e}: use 1-16 lower-case letters and digits, without the dot επέκταση {e}: χρησιμοποιήστε 1-16 πεζά γράμματα και ψηφία, χωρίς την τελεία @@ -6410,11 +6413,11 @@ axis Y Axis Άξονας Y @msg a painted live effect returns only `objects` ένα ζωντανό εφέ βαφής επιστρέφει μόνο `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ένα κομμάτι ζωντανού εφέ βαφής έχει μόνο `path`, `fillRule`, `fills` και `strokes` @msg a read-only object was left out παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση -@msg a read-only object was left out: return it, as {"id"} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {"id"} μόνο ή όπως δόθηκε +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {{"id": …}} μόνο ή όπως δόθηκε @msg a string συμβολοσειρά @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` ένα περίγραμμα έχει μόνο `paint`, `width`, `join`, `cap` και `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} ένα περίγραμμα πρέπει να είναι {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ένα εφέ πρέπει να είναι {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} ένα περίγραμμα πρέπει να είναι {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ένα εφέ πρέπει να είναι {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` ένα εφέ σε γέμισμα επιστρέφει μόνο `fills` @msg an effect on a stroke returns only `strokes` ένα εφέ σε περίγραμμα επιστρέφει μόνο `strokes` @msg an object must be a JSON object ένα αντικείμενο πρέπει να είναι αντικείμενο JSON @@ -7422,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 Ανανέωση εφέ σε αναμονή @@ -7451,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 89ea2c088c..3e8bd23752 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2666,6 +2666,7 @@ font-technology Variable Variables Proportional Metrics Métrica proporcional Insert Aki Left Insertar aki a la izquierda Insert Aki Right Insertar aki a la derecha + Tsume Tsume Character Direction Dirección de caracteres Digits Dígitos Arabic Árabes @@ -4744,6 +4745,7 @@ axis Both Ambos @msg no action set to save no hay ningún conjunto de acciones que guardar @msg the action set file can't be read in full el archivo de conjunto de acciones no se puede leer completo @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos +@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}` Se cargó “{name}”: {n} paso tiene un comando desconocido y se conservó: “{action}”, paso {step}, `{command}`|Se cargó “{name}”: {n} pasos tienen comandos desconocidos y se conservaron; el primero: “{action}”, paso {step}, `{command}` @msg not an action set file no es un archivo de conjunto de acciones @msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referencia superaría los {MAX_BYTES} bytes @@ -4985,6 +4987,7 @@ axis Both Ambos @msg tool shortcuts must be a single key (optionally with Shift) los atajos de herramientas deben ser una sola tecla (opcionalmente con Shift) @msg truncated truncado @msg unknown command `{0}` comando desconocido `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` el paso {step} de “{action}” ejecuta el comando desconocido `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando desconocido `{0}`; ¿quiso decir `{1}`? @msg unknown effect `{id}` efecto desconocido `{id}` @msg unknown format formato desconocido @@ -6336,8 +6339,8 @@ axis Y Axis Eje Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de exportación declara de 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` como máximo {MAX_UNGROUPED} parámetros fuera de grupos: asigna un `group` a los demás @msg can't encode the input no se puede codificar la entrada -@msg expected {"dataBase64"} se esperaba {"dataBase64"} -@msg expected {"objects"} se esperaba {"objects"} +@msg expected {{"dataBase64": "…"}} se esperaba {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} se esperaba {{"objects": [...]}} @msg export `{name}`: {e} exportación del módulo `{name}`: {e} @msg extension {e} is listed twice la extensión {e} aparece dos veces @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensión {e}: usa 1-16 letras minúsculas y dígitos, sin el punto @@ -6401,11 +6404,11 @@ axis Y Axis Eje Y @msg a painted live effect returns only `objects` un efecto dinámico de pintura solo devuelve `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una pieza de un efecto dinámico de pintura solo tiene `path`, `fillRule`, `fills` y `strokes` @msg a read-only object was left out se omitió un objeto de solo lectura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {"id"} solo o tal como se recibió +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {{"id": …}} solo o tal como se recibió @msg a string una cadena @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un trazo solo tiene `paint`, `width`, `join`, `cap` y `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un trazo debe ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un efecto debe ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un trazo debe ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un efecto debe ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un efecto en un relleno solo devuelve `fills` @msg an effect on a stroke returns only `strokes` un efecto en un trazo solo devuelve `strokes` @msg an object must be a JSON object un objeto debe ser un objeto JSON @@ -7329,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 @@ -7413,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 655936f6c2..d408220f47 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1576,6 +1576,7 @@ lighting Light Valo Proportional Metrics Suhteelliset metriikat Insert Aki Left Lisää aki vasemmalle Insert Aki Right Lisää aki oikealle + Tsume Tsume Character Direction Merkkien suunta Digits Numerot Arabic Arabia @@ -4960,6 +4961,7 @@ imageTrace Outline Ääriviiva @msg no action set to save ei tallennettavaa toimintosarjaa @msg the action set file can't be read in full toimintosarjatiedostoa ei voi lukea kokonaan @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta +@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}` Ladattiin ”{name}”: {n} vaiheella on tuntematon komento, ja se säilytettiin: ”{action}”, vaihe {step}, `{command}`|Ladattiin ”{name}”: {n} vaiheella on tuntemattomia komentoja, ja ne säilytettiin; ensimmäinen: ”{action}”, vaihe {step}, `{command}` @msg not an action set file ei ole toimintosarjatiedosto @msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: viite olisi suurempi kuin {MAX_BYTES} tavua @@ -5193,6 +5195,7 @@ imageTrace Outline Ääriviiva @msg tool shortcuts must be a single key (optionally with Shift) työkalujen pikanäppäinten on oltava yksittäinen näppäin (valinnaisesti Vaihto-näppäimen kanssa) @msg truncated katkaistu @msg unknown command `{0}` tuntematon komento `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` toiminnon ”{action}” vaihe {step} suorittaa tuntemattoman komennon `{command}` @msg unknown command `{0}`; did you mean `{1}`? tuntematon komento `{0}`; tarkoititko `{1}`? @msg unknown effect `{id}` tuntematon tehoste `{id}` @msg unknown format tuntematon muoto @@ -6423,8 +6426,8 @@ axis Y Axis Y-akseli @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` vientilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ryhmien ulkopuolella saa olla enintään {MAX_UNGROUPED} parametria: anna muille `group` @msg can't encode the input syötettä ei voi koodata -@msg expected {"dataBase64"} odotettiin {"dataBase64"} -@msg expected {"objects"} odotettiin {"objects"} +@msg expected {{"dataBase64": "…"}} odotettiin {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} odotettiin {{"objects": [...]}} @msg export `{name}`: {e} moduulin vienti `{name}`: {e} @msg extension {e} is listed twice tunniste {e} on luettelossa kahdesti @msg extension {e}: use 1-16 lower-case letters and digits, without the dot tunniste {e}: käytä 1-16 pientä kirjainta ja numeroa ilman pistettä @@ -6488,11 +6491,11 @@ axis Y Axis Y-akseli @msg a painted live effect returns only `objects` maalaava live-tehoste palauttaa vain kentän `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` maalaavan live-tehosteen osalla on vain `path`, `fillRule`, `fills` ja `strokes` @msg a read-only object was left out vain luku -objekti jätettiin pois -@msg a read-only object was left out: return it, as {"id"} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {"id"}-muotona tai sellaisena kuin se annettiin +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {{"id": …}}-muotona tai sellaisena kuin se annettiin @msg a string merkkijono @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` viivalla on vain `paint`, `width`, `join`, `cap` ja `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} viivan on oltava muotoa {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} tehosteen on oltava muotoa {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} viivan on oltava muotoa {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} tehosteen on oltava muotoa {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` täytön tehoste palauttaa vain kentän `fills` @msg an effect on a stroke returns only `strokes` viivan tehoste palauttaa vain kentän `strokes` @msg an object must be a JSON object objektin on oltava JSON-objekti @@ -7504,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 @@ -7533,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 2396640141..318bbbeb44 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2658,6 +2658,7 @@ font-technology Variable Variables Proportional Metrics Métrique proportionnelle Insert Aki Left Insérer un aki à gauche Insert Aki Right Insérer un aki à droite + Tsume Tsume Character Direction Direction des caractères Digits Chiffres Arabic Arabes @@ -4733,6 +4734,7 @@ axis Both Les deux @msg no action set to save aucun jeu de scripts à enregistrer @msg the action set file can't be read in full le fichier de jeu de scripts ne peut pas être lu en entier @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées +@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} » chargé : {n} étape a une commande inconnue et a été conservée : « {action} », étape {step}, `{command}`|« {name} » chargé : {n} étapes ont des commandes inconnues et ont été conservées ; la première : « {action} », étape {step}, `{command}` @msg not an action set file ce n’est pas un fichier de jeu de scripts @msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}` : la référence dépasserait {MAX_BYTES} octets @@ -4974,6 +4976,7 @@ axis Both Les deux @msg tool shortcuts must be a single key (optionally with Shift) les raccourcis d’outils doivent être une seule touche (éventuellement avec Maj) @msg truncated tronqué @msg unknown command `{0}` commande inconnue `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` l’étape {step} de « {action} » exécute la commande inconnue `{command}` @msg unknown command `{0}`; did you mean `{1}`? commande inconnue `{0}` ; vouliez-vous dire `{1}` ? @msg unknown effect `{id}` effet inconnu `{id}` @msg unknown format format inconnu @@ -6388,8 +6391,8 @@ axis Y Axis Axe Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’exportation déclare de 1 à {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` {MAX_UNGROUPED} paramètres au plus hors des groupes : donnez un `group` aux autres @msg can't encode the input impossible d’encoder l’entrée -@msg expected {"dataBase64"} attendu : {"dataBase64"} -@msg expected {"objects"} attendu : {"objects"} +@msg expected {{"dataBase64": "…"}} attendu : {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} attendu : {{"objects": [...]}} @msg export `{name}`: {e} export du module `{name}` : {e} @msg extension {e} is listed twice l’extension {e} figure deux fois @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extension {e} : utilisez 1-16 lettres minuscules et chiffres, sans le point @@ -6453,11 +6456,11 @@ axis Y Axis Axe Y @msg a painted live effect returns only `objects` un effet dynamique de peinture renvoie seulement `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` une pièce d’un effet dynamique de peinture a seulement `path`, `fillRule`, `fills` et `strokes` @msg a read-only object was left out un objet en lecture seule a été omis -@msg a read-only object was left out: return it, as {"id"} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {"id"} seul ou tel qu’il a été fourni +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {{"id": …}} seul ou tel qu’il a été fourni @msg a string une chaîne @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un contour a seulement `paint`, `width`, `join`, `cap` et `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un contour doit être {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un effet doit être {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un contour doit être {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un effet doit être {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un effet sur un fond renvoie seulement `fills` @msg an effect on a stroke returns only `strokes` un effet sur un contour renvoie seulement `strokes` @msg an object must be a JSON object un objet doit être un objet JSON @@ -7464,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 @@ -9297,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 0b2a25d4b2..4bbdfd6abd 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3698,6 +3698,7 @@ antiAlias Sharp शार्प Proportional Metrics आनुपातिक मीट्रिक Insert Aki Left बाईं ओर आकी जोड़ें Insert Aki Right दाईं ओर आकी जोड़ें + Tsume त्सुमे Proportional Oldstyle आनुपातिक ओल्डस्टाइल Protect Corners कॉर्नर को सुरक्षित रखें Proximity Guides निकटता गाइड @@ -5616,11 +5617,11 @@ antiAlias Sharp शार्प @msg a painted live effect returns only `objects` पेंट करने वाला लाइव इफ़ेक्ट केवल `objects` लौटाता है @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` पेंट करने वाले लाइव इफ़ेक्ट के हिस्से में केवल `path`, `fillRule`, `fills` और `strokes` होते हैं @msg a read-only object was left out एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया -@msg a read-only object was left out: return it, as {"id"} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {"id"} के रूप में या जैसा दिया गया था वैसा लौटाएँ +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {{"id": …}} के रूप में या जैसा दिया गया था वैसा लौटाएँ @msg a string एक स्ट्रिंग @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` स्ट्रोक में केवल `paint`, `width`, `join`, `cap` और `miterLimit` होते हैं -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} स्ट्रोक {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} होना चाहिए -@msg an effect must be {"id", "params"?, "visible"?} इफ़ेक्ट {"id", "params"?, "visible"?} होना चाहिए +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} स्ट्रोक {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} होना चाहिए +@msg an effect must be {{"id", "params"?, "visible"?}} इफ़ेक्ट {{"id", "params"?, "visible"?}} होना चाहिए @msg an effect on a fill returns only `fills` फ़िल पर इफ़ेक्ट केवल `fills` लौटाता है @msg an effect on a stroke returns only `strokes` स्ट्रोक पर इफ़ेक्ट केवल `strokes` लौटाता है @msg an object must be a JSON object ऑब्जेक्ट एक JSON ऑब्जेक्ट होना चाहिए @@ -6084,7 +6085,7 @@ antiAlias Sharp शार्प @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` में पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइलों के 1 से 1000 पाथ होने चाहिए @msg `folder` must be an absolute folder path `folder` एक पूर्ण फ़ोल्डर पाथ होना चाहिए @msg `follow` must be "average", "a" or "b" `follow` को "average", "a" या "b" होना चाहिए। -@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` को अक्ष टैग और मानों का एक ऑब्जेक्ट होना चाहिए, जैसे कि {"wght"} +@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` को अक्ष टैग और मानों का एक ऑब्जेक्ट होना चाहिए, जैसे कि {{"wght": 650}} @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` को "emBox", "capHeight", "xHeight" या "icfBox" होना चाहिए @msg `format` must be a string `format` एक स्ट्रिंग होना चाहिए @msg `format` must be one of {_1} `format` को {_1} में से एक होना चाहिए @@ -6118,12 +6119,12 @@ antiAlias Sharp शार्प @msg `index` must be a point index `index` एक बिंदु सूचकांक होना चाहिए @msg `index` must be a whole number or null `index` होना चाहिए पूरी संख्या या null होना चाहिए @msg `indices` must be a non-empty array of item indices `indices` आइटम सूचकांकों की एक गैर-खाली सरणी होना चाहिए -@msg `items` is a list of {kind, item} `items` {kind, item} की सूची है +@msg `items` is a list of {{kind, item}} `items` {{kind, item}} की सूची है @msg `items` is empty `items` खाली है @msg `items` must be a list of {{id, anchors?}}, not {v} `items` को {{id, anchors?}} की सूची होना चाहिए, {v} नहीं -@msg `items` must be a list of {id, points} `items` को {id, points} की सूची होना चाहिए +@msg `items` must be a list of {{id, points}} `items` को {{id, points}} की सूची होना चाहिए @msg `items` must be an array of {{id, corners?}}, not {v} `items` को {{id, corners?}} की ऐरे होना चाहिए, {v} नहीं -@msg `items` must be an array of {id, anchors} `items` को {id, anchors} की ऐरे होना चाहिए +@msg `items` must be an array of {{id, anchors}} `items` को {{id, anchors}} की ऐरे होना चाहिए @msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` होना चाहिए "none", "short", "medium", "long" या "stylistic"। @msg `kind` is one of {_1} `kind` {_1} में से एक है @msg `kind` must be "line" or "paragraph", not {other} `kind` होना चाहिए "line" या "paragraph", नहीं {other} @@ -6408,7 +6409,7 @@ antiAlias Sharp शार्प @msg artboard {i} doesn't print आर्टबोर्ड {i} प्रिंट नहीं करता है @msg artboard is a 0-based index artboard 0 से शुरू होने वाला इंडेक्स है @msg artboard must be a 0-based index artboard 0 से शुरू होने वाला इंडेक्स होना चाहिए -@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout को {layout?, columns?, spacing?, rightToLeft?} होना चाहिए +@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout को {{layout?, columns?, spacing?, rightToLeft?}} होना चाहिए @msg artboardLayout.columns must be 1 or more artboardLayout.columns को 1 या अधिक होना चाहिए @msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout को gridByRow, gridByColumn, row या column होना चाहिए @msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft को true या false होना चाहिए @@ -6777,13 +6778,13 @@ antiAlias Sharp शार्प @msg DXF scale must be a positive number up to {_1}, not {scale} DXF स्केल {_1} तक की धनात्मक संख्या होनी चाहिए, न कि {scale} @msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf इकाई `{u}`: एक इकाई जैसे mm, cm, in या pt @msg dxf: {e} dxf: {e} -@msg each format is an object {format, scale?, suffix?} हर फ़ॉर्मैट एक ऑब्जेक्ट {format, scale?, suffix?} है +@msg each format is an object {{format, scale?, suffix?}} हर फ़ॉर्मैट एक ऑब्जेक्ट {{format, scale?, suffix?}} है @msg each item needs an id प्रत्येक आइटम को एक आईडी की आवश्यकता होती है @msg each match needs `kind` प्रत्येक मैच की जरूरत है `kind` @msg each match needs a `name` प्रत्येक मैच को `name` की आवश्यकता होती है -@msg each of `fonts` is {family, style?}; 1 to 1000 of them `fonts` का हर आइटम {family, style?} है; 1 से 1000 आइटम +@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them `fonts` का हर आइटम {{family, style?}} है; 1 से 1000 आइटम @msg each of `items` is {{id, corners?}} with an object id, not {it} `items` का हर आइटम ऑब्जेक्ट आईडी वाला {{id, corners?}} है, {it} नहीं -@msg each of `items` is {kind, item} `items` का हर आइटम {kind, item} है +@msg each of `items` is {{kind, item}} `items` का हर आइटम {{kind, item}} है @msg each row needs `from` हर पंक्ति के लिए `from` चाहिए @msg each stop needs a position प्रत्येक स्टॉप को स्थिति की आवश्यकता होती है @msg each subpath needs an anchors array प्रत्येक उपपाथ को एक एंकर सरणी की आवश्यकता होती है @@ -6815,8 +6816,8 @@ antiAlias Sharp शार्प @msg Expand: no gradient fills एक्सपैंड: कोई ग्रेडिएंट फ़िल नहीं @msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं @msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है -@msg expected {"dataBase64"} अपेक्षित: {"dataBase64"} -@msg expected {"objects"} अपेक्षित: {"objects"} +@msg expected {{"dataBase64": "…"}} अपेक्षित: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} अपेक्षित: {{"objects": [...]}} @msg export `{name}`: {e} निर्यात `{name}`: {e} @msg Export cancelled निर्यात रद्द @msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft स्क्रीन के लिए एक्सपोर्ट png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai या vectorcraft लिखता है @@ -6836,7 +6837,7 @@ antiAlias Sharp शार्प @msg features must be a list of tags features टैग की एक सूची होनी चाहिए @msg fidelity must be 0..100 fidelity 0..100 होना चाहिए @msg file version {0} is newer than this VectorCraft supports ({VERSION}) फ़ाइल संस्करण {0} इस वेक्टर क्रेफ्ट समर्थन ({VERSION}) की तुलना में नया है। -@msg files must be {name, dataBase64} files {name, dataBase64} होना चाहिए +@msg files must be {{name, dataBase64}} files {{name, dataBase64}} होना चाहिए @msg finish editing the pattern first पहले पैटर्न को संपादित करना @msg First measured segment no longer exists पहले मापा खंड अब मौजूद नहीं है @msg fit must be none|autoHeight|shrinkText|block, got {id} fit को none|autoHeight|shrinkText|block होना चाहिए, {id} मिला @@ -6862,7 +6863,7 @@ antiAlias Sharp शार्प @msg frequency must be between 1 and 300 lines per inch frequency प्रति इंच 1 से 300 लाइनों के बीच होनी चाहिए @msg from and to must be finite से और होना चाहिए करने के लिए @msg from must be [x0, y0, x1, y1] from [x0, y0, x1, y1] होना चाहिए -@msg give {key, value} or {values} {key, value} या {values} दें +@msg give {{key, value}} or {{values}} {{key, value}} या {{values}} दें @msg give `{a}` and `{b}` `{a}` और `{b}` दें @msg give `anchor`, the index of the anchor to slide खिसकाने वाले एंकर का इंडेक्स `anchor` दें @msg give `before` or `after`, not both `before` या `after` दें, दोनों नहीं @@ -6880,7 +6881,7 @@ antiAlias Sharp शार्प @msg give `id` or `guide`, not both `id` या `guide` दें, दोनों नहीं @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` या `overprintBlack` दें @msg give `length` or `to` `length` या `to` देना -@msg give `matches`: [{kind, name, target}] `matches`: [{kind, name, target}] +@msg give `matches`: [{{kind, name, target}}] `matches`: [{{kind, name, target}}] @msg give `name` (or edit a pattern) `name` (या एक पैटर्न संपादित करें) @msg give `name` or `names` `name` या `names` दें @msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (.wasm फ़ाइल) या `dataBase64` (मॉड्यूल) दें @@ -6937,7 +6938,7 @@ antiAlias Sharp शार्प @msg give the preset a `name` प्रीसेट देना `name` @msg give the preset a name प्रीसेट को एक नाम देना @msg give the preset a name (1–64 characters) प्रीसेट का नाम दें (1–64 अक्षर) -@msg give the settings to change, e.g. {"formats"}]} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {"formats"}]} +@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {{"formats": [{{"format": "png", "scale": "2x"}}]}} @msg give the style a name स्टाइल को एक नाम दें @msg give two or more style `names` दो या अधिक स्टाइल `names` @msg give two or more swatches दो या अधिक स्वॉच दें @@ -7157,7 +7158,7 @@ antiAlias Sharp शार्प @msg missing `library` (see graphicStyle.libraries) लापता `library` (graphicStyle.libraries देखें) @msg missing `library` (see swatch.library.list) लापता `library` (swatch.library.list देखें) @msg missing `library` (see symbol.libraries) `library` अनुपस्थित है (symbol.libraries देखें) -@msg missing `map` (rows [{from, to}] or {current}) लापता `map` (rows [{from, to}] या {current}) +@msg missing `map` (rows [{{from, to}}] or {{current: new}}) लापता `map` (rows [{{from, to}}] या {{current: new}}) @msg missing `matrix` [a,b,c,d,e,f] लापता `matrix` [a,b,c,d,e,f] @msg missing `name` लापता `name` @msg missing `name` (see gradient.presets.list) लापता `name` (gradient.presets.list देखें) @@ -7519,6 +7520,7 @@ antiAlias Sharp शार्प @msg not a Word document: its WordDocument stream doesn't start with a file information block वर्ड डॉक्यूमेंट नहीं: इसकी WordDocument धारा फ़ाइल सूचना ब्लॉक के साथ शुरू नहीं होती है @msg the action set file can't be read in full एक्शन सेट फ़ाइल पूरी तरह नहीं पढ़ी जा सकती @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए +@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}” लोड किया गया: {n} चरण में अज्ञात आदेश है और उसे रखा गया: “{action}”, चरण {step}, `{command}`|“{name}” लोड किया गया: {n} चरणों में अज्ञात आदेश हैं और उन्हें रखा गया; पहला: “{action}”, चरण {step}, `{command}` @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an HP-GL file HP-GL फ़ाइल नहीं है @@ -7527,7 +7529,7 @@ antiAlias Sharp शार्प @msg not an export presets file (format `{_1}`) यह एक्सपोर्ट प्रीसेट फ़ाइल नहीं है (फ़ॉर्मैट `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) -@msg not an RTF file: it doesn't start with {\\rtf RTF फ़ाइल नहीं: यह {\rtf के साथ शुरू नहीं होता है +@msg not an RTF file: it doesn't start with {{\\rtf RTF फ़ाइल नहीं: यह {{\rtf के साथ शुरू नहीं होता है @msg not area type (text in a frame) outside perspective पर्सपेक्टिव के बाहर का एरिया टाइप (फ़्रेम में टेक्स्ट) नहीं है @msg not editing an opacity mask ओपेसिटी मास्क संपादित नहीं किया जा रहा है @msg not found नहीं मिला @@ -7724,7 +7726,7 @@ antiAlias Sharp शार्प @msg printing isn't available here मुद्रण यहाँ उपलब्ध नहीं है @msg procedures nest too deeply प्रक्रियाओं घोंसला बहुत गहराई से @msg procedures nested too deeply प्रक्रियाओं ने बहुत गहराई से घोंसले -@msg quotes must be {double?, single?} quotes को {double?, single?} होना चाहिए +@msg quotes must be {{double?, single?}} quotes को {{double?, single?}} होना चाहिए @msg quotes.{k} must be an opening and a closing quote, e.g. "“”" उद्धरण.{k} एक उद्घाटन और एक समापन उद्धरण होना चाहिए, उदाहरण के लिए "“”" @msg range `{s}` is empty श्रेणी `{s}` खाली है @msg range `{s}`: `{part}` names no number सीमा `{s}`: `{part}` कोई संख्या नहीं बताता @@ -7965,14 +7967,14 @@ antiAlias Sharp शार्प @msg Switch x/y is for scatter graphs स्विच x/y स्कैटर ग्राफ़ के लिए है @msg t, dx and dy must be finite टी, डीएक्स और डीआई को परिमित होना चाहिए @msg Targa files are RGB: colorModel rgb Targa फ़ाइलें RGB हैं: colorModel rgb -@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text को {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} होना चाहिए +@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text को {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} होना चाहिए @msg Text options: {e} टेक्स्ट ऑप्शन्स: {e} @msg text transform is collapsed or unrepresentable टेक्स्ट ट्रांसफ़ॉर्म सिमटा हुआ है या उसे दर्शाया नहीं जा सकता @msg Text Wrap: select a wrap object टेक्स्ट लपेटें: एक रैप ऑब्जेक्ट चुनें @msg text.{key} `{v}`: use "{_1}" or "{_2}" Text.{key} `{v}`: "{_1}" या "{_2}" का उपयोग करें। @msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces को 2 से 100 के बीच पूर्णांक होना चाहिए @msg textureImage: {e} textureImage: {e} -@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {path} या {name, dataBase64} दें, या effect.list द्वारा दिया गया टेक्सचर दें +@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {{path}} या {{name, dataBase64}} दें, या effect.list द्वारा दिया गया टेक्सचर दें @msg that anchor can't slide: an end of an open path has nowhere to go कि एंकर स्लाइड नहीं कर सकते: एक खुले रास्ते का अंत कहीं नहीं जाना है @msg the active document changed while the question was open; nothing was deleted प्रश्न खुला रहते हुए सक्रिय डॉक्यूमेंट बदल गया; कुछ भी नहीं हटाया गया @msg that document is no longer open यह डॉक्यूमेंट अब खुला नहीं है @@ -8409,6 +8411,7 @@ antiAlias Sharp शार्प @msg unknown character style number field अज्ञात कैरेक्टर स्टाइल संख्या फ़ील्ड @msg unknown CMYK profile `{name}` अज्ञात CMYK प्रोफ़ाइल `{name}` @msg unknown command `{0}` अज्ञात आदेश `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}” का चरण {step} अज्ञात आदेश `{command}` चलाता है @msg unknown command `{0}`; did you mean `{1}`? अज्ञात आदेश `{0}`; क्या आपको `{1}` का मतलब है? @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode अज्ञात नियंत्रण `{name}`: fillStroke, रंग, drawMode या screenMode @msg Unknown dimension mode अज्ञात आयाम मोड @@ -8860,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 925670bbc3..a1f6100b2f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3224,6 +3224,7 @@ mark-style Japanese Japán Proportional Metrics Arányos metrika Insert Aki Left Aki beszúrása balra Insert Aki Right Aki beszúrása jobbra + Tsume Tsume Proportional Lining Arányos modern Position: Pozíció: Default Position Alapértelmezett pozíció @@ -5433,6 +5434,7 @@ imageTrace Outline Körvonal @msg no action set to save nincs menthető műveletkészlet @msg the action set file can't be read in full a műveletkészlet-fájl nem olvasható be teljesen @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n} +@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}” betöltve; megtartott lépések ismeretlen paranccsal: {n}; az első: „{action}”, lépés: {step}, `{command}` @msg not an action set file nem műveletkészlet-fájl @msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referencia nagyobb lenne {MAX_BYTES} bájtnál @@ -5806,6 +5808,7 @@ imageTrace Outline Körvonal @msg truncated csonka @msg Unembedded to {_1} Beágyazás megszüntetve, ide: {_1} @msg unknown command `{0}` ismeretlen parancs: `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` „{action}”, lépés: {step}: ismeretlen parancsot futtat: `{command}` @msg unknown command `{0}`; did you mean `{1}`? ismeretlen parancs: `{0}`; erre gondolt: `{1}`? @msg unknown effect `{id}` ismeretlen effektus: `{id}` @msg unknown format ismeretlen formátum @@ -6376,8 +6379,8 @@ axis Y Axis Y tengely @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` az exportáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` csoporton kívül legfeljebb {MAX_UNGROUPED} paraméter lehet: a többinek adj meg `group` értéket @msg can't encode the input a bemenet nem kódolható -@msg expected {"dataBase64"} várt formátum: {"dataBase64"} -@msg expected {"objects"} várt formátum: {"objects"} +@msg expected {{"dataBase64": "…"}} várt formátum: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} várt formátum: {{"objects": [...]}} @msg export `{name}`: {e} `{name}` modulexport: {e} @msg extension {e} is listed twice a(z) {e} kiterjesztés kétszer szerepel @msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} kiterjesztés: 1-16 kisbetűt és számjegyet használj, pont nélkül @@ -6441,11 +6444,11 @@ axis Y Axis Y tengely @msg a painted live effect returns only `objects` a festő élő effektus csak `objects` értéket ad vissza @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` a festő élő effektus darabjának csak `path`, `fillRule`, `fills` és `strokes` kulcsa van @msg a read-only object was left out kimaradt egy írásvédett objektum -@msg a read-only object was left out: return it, as {"id"} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {"id"} formában vagy úgy, ahogy megkapta +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {{"id": …}} formában vagy úgy, ahogy megkapta @msg a string karakterlánc @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` a körvonalnak csak `paint`, `width`, `join`, `cap` és `miterLimit` kulcsa van -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} a körvonal formája {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} legyen -@msg an effect must be {"id", "params"?, "visible"?} az effektus formája {"id", "params"?, "visible"?} legyen +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} a körvonal formája {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} legyen +@msg an effect must be {{"id", "params"?, "visible"?}} az effektus formája {{"id", "params"?, "visible"?}} legyen @msg an effect on a fill returns only `fills` a kitöltésen lévő effektus csak `fills` értéket ad vissza @msg an effect on a stroke returns only `strokes` a körvonalon lévő effektus csak `strokes` értéket ad vissza @msg an object must be a JSON object az objektum JSON-objektum legyen @@ -7453,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 @@ -7482,3 +7491,1934 @@ breakText Glyphs Glifák Grommets Ringlik Grommet Inset Ringlik távolsága a széltől Diameter Átmérő +# 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 a 'caller', a 'callee' és az 'arguments' nem használható szigorú módú függvényeken +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Egy profilon egy Forgatás tengely körül effektus lehet. Inkább a meglévő effektus beállításait szerkeszd. +@msg Add Points at Crossings: a path went missing Pontok hozzáadása a kereszteződésekben: egy útvonal eltűnt +@msg Alignment must be one of the choices Az igazítás csak a választási lehetőségek egyike lehet +@msg Boolean.prototype.{name} was called on a value that is not a Boolean a Boolean.prototype.{name} hívása olyan értéken történt, amely nem Boolean +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB Azok a CMYK-képek, amelyek mintái nem voltak CMYK-ban olvashatók, RGB-ben lettek importálva +@msg CSS options: {e} CSS-beállítások: {e} +@msg CSV has a header but no data rows A CSV-ben van fejléc, de nincsenek adatsorok +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark A CSV NUL bájtokat tartalmaz: mentsd UTF-8-ként, vagy bájtsorrendjellel ellátott UTF-16-ként +@msg CSV is empty A CSV üres +@msg CSV is larger than {MAX_CSV_BYTES} bytes A CSV nagyobb, mint {MAX_CSV_BYTES} bájt +@msg CSV is not UTF-16 A CSV nem UTF-16 kódolású +@msg CSV is not UTF-8 A CSV nem UTF-8 kódolású +@msg CSV would be larger than {MAX_CSV_BYTES} bytes A CSV nagyobb lenne, mint {MAX_CSV_BYTES} bájt +@msg Cannot write to the location of “{name}”; choose where to save it Nem lehet írni ennek a helyére: „{name}”; válaszd ki, hová kerüljön a mentés +@msg Choose original artwork, not a dimension's generated paths Eredeti grafikát válassz, ne egy méretvonal generált útvonalait +@msg Contour: select paths or shapes Kontúr: jelölj ki útvonalakat vagy alakzatokat +@msg Contour: the Offset Path effect is not available Kontúr: az Útvonal eltolása effektus nem érhető el +@msg Create needs fills, strokes or both A létrehozáshoz kitöltések, körvonalak vagy mindkettő kell +@msg DCS files can only be placed linked: use File › Place with Link on A DCS-fájlok csak csatolva helyezhetők el: használd a Fájl › Elhelyezés parancsot bekapcsolt Csatolás beállítással +@msg DXF options: {e} DXF-beállítások: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} A DXF-lépték pozitív szám lehet, legfeljebb {_1}; ez nem az: {scale} +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Date.prototype method called on a value that is not a Date object Date.prototype metódus hívása olyan értéken történt, amely nem Date objektum +@msg Dimension is locked or hidden A méretvonal zárolt vagy rejtett +@msg Dimension mode does not match its source A méretvonal módja nem egyezik a forrásával +@msg Draw Inside needs exactly one selected path, compound path or text A Rajzolás belülre módhoz pontosan egy kijelölt útvonal, összetett útvonal vagy szöveg kell +@msg EPS Save writes all artwork: use document.export for selected-only EPS Az EPS-mentés a teljes grafikát kiírja: csak a kijelölést tartalmazó EPS-hez használd a document.export parancsot +@msg EPS flattener: {e} EPS-lapító: {e} +@msg EPS options: {e} EPS-beállítások: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Error.prototype.toString was called on a value that is not an object az Error.prototype.toString hívása olyan értéken történt, amely nem objektum +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Az Exportálás képernyőkre ezeket írja: png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai vagy vectorcraft +@msg Flare click coordinates must be finite numbers A Fényvillanás kattintási koordinátái véges számok legyenek +@msg Function.prototype.toString was called on a value that is not a function a Function.prototype.toString hívása olyan értéken történt, amely nem függvény +@msg HTML Text applies to object slices of type only A HTML-szöveg csak szövegobjektumból készült szeletekre alkalmazható +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Az Objektumok azonosítása beállítás XML-azonosító: a névnek érvényes XML-azonosítónak kell lennie (betűvel, aláhúzásjellel vagy kettősponttal kezdődik; szóköz nélkül) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) A képvektorizálás nem sikerült: belső hiba: a szála leállt (kérjük, jelentsd ezt a hibát) +@msg Image Trace failed: {e} A képvektorizálás nem sikerült: {e} +@msg Image Trace finished, but its document was closed A képvektorizálás elkészült, de a dokumentumát bezárták +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again A képvektorizálás elkészült, de a kép vagy a beállításai közben megváltoztak: vektorizáld újra +@msg Import SVG Filter reads SVG files, not SVGZ Az SVG-szűrő importálása SVG-fájlokat olvas, SVGZ-fájlokat nem +@msg Invalid dimension offset Érvénytelen méretvonal-eltolás +@msg Invalid point Érvénytelen pont +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (eltolás: {at}) +@msg JSON.stringify cannot write a structure that contains itself a JSON.stringify nem tud olyan szerkezetet kiírni, amely önmagát tartalmazza +@msg Join: a subpath went missing Összekapcsolás: egy részútvonal eltűnt +@msg Line Gap must be a number A Sorköz szám kell legyen +@msg Lock Workspace is not available in VectorCraft A Munkaterület zárolása nem érhető el a VectorCraftban +@msg Move Panel to New Window is not available: floating panels stay in the main window A Panel áthelyezése új ablakba nem érhető el: a lebegő panelek a főablakban maradnak +@msg Namespace.prototype.toString needs a Namespace a Namespace.prototype.toString hívásához Namespace kell +@msg New Document profiles need the preferences folder, which this run doesn't have Az új dokumentumok profiljaihoz a beállítások mappája kell, amely ebben a futásban nincs meg +@msg Number.prototype.{name} was called on a value that is not a number a Number.prototype.{name} hívása olyan értéken történt, amely nem szám +@msg Object.create's prototype must be an object or null az Object.create prototípusa objektum vagy null lehet +@msg Object.{function} was given a value that is not an object az Object.{function} olyan értéket kapott, amely nem objektum +@msg Offset Path: the path is too complex to offset Útvonal eltolása: az útvonal túl összetett az eltoláshoz +@msg Offset Path: {e} Útvonal eltolása: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported A PDF tartalma hiányos, vagy túllépi az olvasó korlátait; nem lett dokumentum importálva +@msg PDF flattener: {e} PDF-lapító: {e} +@msg PDF options: {e} PDF-beállítások: {e} +@msg QName.prototype.toString needs a QName a QName.prototype.toString hívásához QName kell +@msg Recolor Artwork is no longer open A Grafika átszínezése már nincs megnyitva +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object a RegExp.prototype.{name} hívása olyan értéken történt, amely nem RegExp objektum +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 Az SVG nem UTF-8, UTF-16 vagy ISO 8859-1 kódolású +@msg Save writes one artboard: name one, or export several with document.export A mentés egy rajztáblát ír ki: nevezz meg egyet, vagy exportálj többet a document.export paranccsal +@msg Select a dimension first Előbb jelölj ki egy méretvonalat +@msg Select dimensions to update Jelöld ki a frissítendő méretvonalakat +@msg Select only dimensions Csak méretvonalakat jelölj ki +@msg Select two or more objects, or a group Jelölj ki két vagy több objektumot, vagy egy csoportot +@msg Shaper needs 1–64 source paths Az Alakformálóhoz 1–64 forrásútvonal kell +@msg Stencil Bridges: the selected art is too large or too detailed to bridge Sablonhidak: a kijelölt grafika túl nagy vagy túl részletes a hidak elhelyezéséhez +@msg String.prototype.toString was called on a value that is not a string a String.prototype.toString hívása olyan értéken történt, amely nem karakterlánc +@msg Switch x/y is for scatter graphs Az x/y felcserélése szóródiagramokhoz való +@msg Text options: {e} Szövegbeállítások: {e} +@msg The {name} profile could not be read, so the new document has the default settings: {reason} Ez a profil nem volt olvasható: {name}; ezért az új dokumentum az alapértelmezett beállításokat kapta: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time Csapdázás: több mint {_1} objektumpár van elég közel a csapdázáshoz, vagy túl sok objektum esik azonos vízszintes tartományba; egyszerre kevesebb objektumot csapdázz +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Gyomláló keret és vonalak: a grafikán kívül nem fér el gyomlálóvonal, és a gyomláló keret ki van kapcsolva +@msg \\ at end of pattern \\ a minta végén +@msg `alignOn` cannot be empty `alignOn`: nem lehet üres +@msg `alignOn` must be a string `alignOn`: karakterlánc kell +@msg `align` is start, center or end `align`: start, center vagy end +@msg `align` must be a string `align`: karakterlánc kell +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors`: [subpath, anchor] párok tömbje kell +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {k} +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {v} +@msg `answer` for this {kind} dialog must be one of: {_1} `answer`: ennél a párbeszédablaknál ({kind}) ezek egyike kell: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers`: több mint {MAX_ANSWERS} választ tartalmaz +@msg `answers` is larger than 1 MiB `answers`: nagyobb 1 MiB-nál +@msg `answers` must be a list of answers `answers`: válaszok listája kell +@msg `artboard` must be the index of an artboard of the document `artboard`: a dokumentum egyik rajztáblájának indexe kell +@msg `artboardsAt` must be [x, y] `artboardsAt`: [x, y] kell +@msg `artboards` must be a list of artboard indices `artboards`: rajztáblaindexek listája kell +@msg `artboards` names an artboard the document does not have `artboards`: olyan rajztáblát nevez meg, amely nincs a dokumentumban +@msg `artboards` names an artboard twice `artboards`: egy rajztáblát kétszer nevez meg +@msg `asset` is the asset id (see assets.list) `asset`: az elem azonosítója (lásd: assets.list) +@msg `at` must be [x, y] (got {at}) `at`: [x, y] kell (kapott érték: {at}) +@msg `at` must be a byte offset `at`: bájteltolás kell +@msg `at` must be a point [x, y] `at`: [x, y] pont kell +@msg `autoLeading` must be a number `autoLeading`: szám kell +@msg `background` must be "", "matte" or #rrggbb `background`: "", "matte" vagy #rrggbb kell +@msg `beside` needs `side` `beside`: `side` is kell hozzá +@msg `burasagari` must be "none", "standard" or "forced" `burasagari`: "none", "standard" vagy "forced" kell +@msg `center` must be [x, y] `center`: [x, y] kell +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign`: "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" vagy "icfBottom" kell +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection`: "default", "leftToRight" vagy "rightToLeft" kell +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke`: `stroke: false` és `ids: []` kell hozzá, `stop` nélkül +@msg `clear` must be a list of type object ids, not {v} `clear`: szövegobjektum-azonosítók listája kell, ez nem az: {v} +@msg `clear` must list type objects; {_1} is not one `clear`: szövegobjektumok listája kell; ez nem az: {_1} +@msg `clear` must not list `id` `clear`: nem szerepelhet benne az `id` +@msg `code` must be a whole number `code`: egész szám kell +@msg `colorMode` must be one of rgb, cmyk `colorMode`: ezek egyike kell: rgb, cmyk +@msg `color` needs a `rule` to make the theme from `color`: `rule` is kell hozzá, amelyből a téma készül +@msg `colors` does not apply to the {name} reduction `colors`: ennél a színcsökkentésnél nem használható: {name} +@msg `colors` is empty `colors`: üres +@msg `colors` must be a whole number from 2 to 256 `colors`: 2 és 256 közötti egész szám kell +@msg `colors` must be at least 9 for the uniform reduction `colors`: az egyenletes színcsökkentéshez legalább 9 kell +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors`: 1 és {MAX_THEME_COLORS} közötti érték kell (kapott érték: {n}) +@msg `columnType` is vertical, uniform, repeating or sliding `columnType`: vertical, uniform, repeating vagy sliding +@msg `columns` must be a whole number 1..50 `columns`: egész szám kell, 1..50 +@msg `commands` must not be empty `commands`: nem lehet üres +@msg `contents` goes with `to` `contents`: a `to` kulccsal együtt használható +@msg `corners` must be sharp, round or square, not `{other}` `corners`: sharp, round vagy square kell, ez nem az: `{other}` +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {v} +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count`: 0 és {MAX_COUNT} közötti egész szám kell +@msg `count` must be a whole number from 1 to 100 `count`: 1 és 100 közötti egész szám kell +@msg `dataBase64` is larger than the module limit `dataBase64`: nagyobb a modulok méretkorlátjánál +@msg `dataBase64` is not valid base64 `dataBase64`: nem érvényes base64 +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly`: nem használható együtt ezekkel: `ids`, `reverse`, `append` +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` és `childLimit`: az összegzést szűkítik; add meg ezt: `summary: true` +@msg `design` is a design name, or null for plain columns `design`: diagramminta neve, vagy null az egyszerű oszlopokhoz +@msg `design` is a design name, or null for the default marker `design`: diagramminta neve, vagy null az alapértelmezett jelölőhöz +@msg `destinationFolder` is required when destination is `folder` `destinationFolder`: kötelező, ha a cél `folder` +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination`: `none`, `saveAndClose` vagy `folder` kell +@msg `diameter` must be from 0.1 to 2500 pt `diameter`: 0.1 és 2500 pt közötti érték kell +@msg `digits` must be one of {_1} `digits`: ezek egyike kell: {_1} +@msg `dimImages` is a percentage or false `dimImages`: százalék vagy false +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction`: "auto", "leftToRight" vagy "rightToLeft" kell +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount`: 0 és 100 közötti szám kell +@msg `each` is the value one design stands for, more than 0 `each`: az az érték, amelyet egy minta jelöl; 0-nál nagyobb +@msg `end` must be "start" or "end" `end`: "start" vagy "end" kell +@msg `ends` applies to joining exactly two paths `ends`: pontosan két útvonal összekapcsolásakor használható +@msg `event` must be one of {_1} `event`: ezek egyike kell: {_1} +@msg `every` must be "all", "odd" or "even", not {v} `every`: "all", "odd" vagy "even" kell, ez nem az: {v} +@msg `expand` must be true or false `expand`: true vagy false kell +@msg `fidelity` must be from {lo} to {hi} pt `fidelity`: {lo} és {hi} pt közötti érték kell +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files`: a legutóbbi text.findFontFiles keresés által talált betűtípusfájlok 1–1000 elérési útja kell +@msg `focal` and `radius` cannot be used together `focal` és `radius`: nem használhatók együtt +@msg `focal` must be an array of [subpath, anchor] pairs `focal`: [subpath, anchor] párok tömbje kell +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal`: ezt nevezi meg: [{si}, {ai}], de ez nem az útvonal horgonypontja +@msg `folder` must be an absolute folder path `folder`: abszolút mappaútvonal kell +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes`: tengelycímkék és értékek objektuma kell, például {"wght"} +@msg `format` must be a string `format`: karakterlánc kell +@msg `format` must be one of {_1} `format`: ezek egyike kell: {_1} +@msg `fractions` is chop or scale `fractions`: chop vagy scale +@msg `from` and `to` must differ `from` és `to`: különbözniük kell +@msg `from` has an empty frame `from`: üres a kerete +@msg `from` must be a non-negative integer `from`: nemnegatív egész szám kell +@msg `from` must be area type or type on a path `from`: területszöveg vagy útvonalszöveg kell +@msg `gid` must be a glyph id, not {v} `gid`: glifaazonosító kell, ez nem az: {v} +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate`: egyetlen karakterhez tartozik, nem karakterstílushoz (a text.setGlyphAlternate állítja be) +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate`: csak null adható meg, amely megszünteti a glifaválasztást (a text.setGlyphAlternate hoz létre ilyet) +@msg `group` must be one of {_1} `group`: ezek egyike kell: {_1} +@msg `group` must be the Objects on Path group of `member` and `target` `group`: a `member` és a `target` Objektumok útvonalon csoportja kell +@msg `guide` must be a guide index (guide.list) `guide`: segédvonalindex kell (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign`: default, left, center vagy right kell +@msg `id` is an object id, not {v} `id`: objektumazonosító, ez nem az: {v} +@msg `id` must be a node id `id`: csomópont-azonosító kell +@msg `id` must be an object id, not {v} `id`: objektumazonosító kell, ez nem az: {v} +@msg `ids` must name Objects on Path groups or objects in them `ids`: Objektumok útvonalon csoportokat vagy a bennük lévő objektumokat kell megneveznie +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids`: legfeljebb {MAX_INSPECT_IDS} azonosítót fogad +@msg `imageMap` must be none, rectangle or polygon `imageMap`: none, rectangle vagy polygon kell +@msg `includeArtboardFill` must be true or false `includeArtboardFill`: true vagy false kell +@msg `includeSubdirs` must be true or false `includeSubdirs`: true vagy false kell +@msg `index` must be a point index `index`: pontindex kell +@msg `index` must be a whole number or null `index`: egész szám vagy null kell +@msg `indices` must be a non-empty array of item indices `indices`: elemindexek nem üres tömbje kell +@msg `item` needs `index` `item`: `index` is kell hozzá +@msg `items` is a list of {kind, item} `items`: {kind, item} elemek listája +@msg `items` is empty `items`: üres +@msg `items` must be a list of {id, points} `items`: {id, points} elemek listája kell +@msg `items` must be a list of {{id, anchors?}}, not {v} `items`: {{id, anchors?}} elemek listája kell, ez nem az: {v} +@msg `items` must be an array of {id, anchors} `items`: {id, anchors} elemek tömbje kell +@msg `items` must be an array of {{id, corners?}}, not {v} `items`: {{id, corners?}} elemek tömbje kell, ez nem az: {v} +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida`: "none", "short", "medium", "long" vagy "stylistic" kell +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore`: a sor első karakteréhez tartozik, nem karakterstílushoz (a text.setRangeStyle állítja be) +@msg `kind` is one of {_1} `kind`: ezek egyike: {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind`: "round", "invertedRound" vagy "chamfer" kell, ez nem az: {v} +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind`: `text`, `visibility`, `image` vagy `graph` kell +@msg `kind` must be a string `kind`: karakterlánc kell +@msg `kind` must be image, noImage or htmlText `kind`: image, noImage vagy htmlText kell +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType`: "pushInFirst", "pushOutFirst" vagy "pushOutOnly" kell +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku`: "none", "hard" vagy "soft" kell +@msg `label` must be a non-empty name of at most 200 characters `label`: nem üres, legfeljebb 200 karakteres név kell +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language`: nyelvnév vagy nyelvcímke kell, például "Turkish" vagy "tr" +@msg `leader` must be a string `leader`: karakterlánc kell +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel`: "romanBaseline" vagy "emBoxTop" kell +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading`: 0.1 és 5000 közötti érték kell, vagy null az automatikushoz +@msg `length` must be a number of at most 1000000 pt either way `length`: szám kell, bármelyik irányban legfeljebb 1000000 pt +@msg `line` must be true or false `line`: true vagy false kell +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max`: 1 és {MAX_SUGGESTIONS} közötti érték kell +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°): nem lehet nagyobb ennél: `maxAngle` ({max_angle}°) +@msg `mode` must be "handles", "straight" or "smooth" `mode`: "handles", "straight" vagy "smooth" kell +@msg `mode` must be "straight", "bezier" or "arc" `mode`: "straight", "bezier" vagy "arc" kell +@msg `mode` must be one of {_1} `mode`: ezek egyike kell: {_1} +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi`: "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" vagy "lineEndHalf" kell +@msg `name` must be a name that is not empty `name`: nem üres név kell +@msg `name` must not be empty `name`: nem lehet üres +@msg `names` is a list of preset names `names`: előbeállítás-nevek listája +@msg `names` lists preset names `names`: előbeállítás-neveket sorol fel +@msg `names` must be a list of names `names`: nevek listája kell +@msg `names` must be a list of swatch names `names`: színmintanevek listája kell +@msg `names` must be an array of style names `names`: stílusnevek tömbje kell +@msg `new` is a design name, or true `new`: diagramminta neve vagy true +@msg `origin` must be [x, y] in points `origin`: [x, y] kell pontban megadva +@msg `path` must be a folder `path`: mappa kell +@msg `path` must be a path object `path`: útvonalobjektum kell +@msg `picks` must be an array (got {v}) `picks`: tömb kell (kapott érték: {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks`: 1 és {MAX_PICKS} közötti számú választást kell tartalmaznia (kapott érték: {_1}) +@msg `pivot` must be a whole number from 0 to 8 `pivot`: 0 és 8 közötti egész szám kell +@msg `points` must be a list of [x, y] points `points`: [x, y] pontok listája kell +@msg `points` must be a list of point indexes `points`: pontindexek listája kell +@msg `points` must be an array of point indices `points`: pontindexek tömbje kell +@msg `points` must be point indexes `points`: pontindexek kellenek +@msg `points` must hold from 2 to 100000 points `points`: 2 és 100000 közötti számú pontot kell tartalmaznia +@msg `port` must be "in" or "out" `port`: "in" vagy "out" kell +@msg `position` is above or below `position`: above vagy below +@msg `preset` must be a string `preset`: karakterlánc kell +@msg `radius` must be 0 or more `radius`: 0 vagy nagyobb érték kell +@msg `ratio` must be from 0.1 to 0.5 `ratio`: 0.1 és 0.5 közötti érték kell +@msg `rect` must be [x, y, width, height] `rect`: [x, y, width, height] kell +@msg `rect` must be [x, y, width, height] with a positive size `rect`: [x, y, width, height] kell pozitív mérettel +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect`: [x0, y0, x1, y1] kell (kapott érték: {r}) +@msg `relation` must be overlap, inside or outside `relation`: overlap, inside vagy outside kell +@msg `replace` must be a string `replace`: karakterlánc kell +@msg `reversed` must be true or false `reversed`: true vagy false kell +@msg `rotate` must be a finite number `rotate`: véges szám kell +@msg `rows` must be lists of numbers or nulls, not {r} `rows`: számok vagy null értékek listái kellenek, ez nem az: {r} +@msg `rule` must be nonZero or evenOdd `rule`: nonZero vagy evenOdd kell +@msg `scale` must be a number from 0.001 to 1000 `scale`: 0.001 és 1000 közötti szám kell +@msg `scale` must be a number in (0, 100] `scale`: a (0, 100] tartományba eső szám kell +@msg `scope.{key}` must be a non-empty array `scope.{key}`: nem üres tömb kell +@msg `scope` must be "document", {artboards} or {layers} `scope`: "document", {artboards} vagy {layers} kell +@msg `scope` must be "selection" or "document", not {other} `scope`: "selection" vagy "document" kell, ez nem az: {other} +@msg `script` must be a string `script`: karakterlánc kell +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes`: adatsorindexek nem üres listája +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes`: `type` vagy `valueAxis` is kell hozzá +@msg `shift` must be a number of points `shift`: pontban megadott szám kell +@msg `showCenter` must be true or false `showCenter`: true vagy false kell +@msg `side` must be "left", "right", "top" or "bottom" `side`: "left", "right", "top" vagy "bottom" kell +@msg `side` places the artboard: leave out x, y, width and height `side`: ez helyezi el a rajztáblát; hagyd el az x, y, width és height értéket +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification`: "fullJustify", "alignLeft", "alignCenter" vagy "alignRight" kell +@msg `size` is a percentage of the base text's size, 1 to 100 `size`: az alapszöveg méretének százaléka, 1 és 100 között +@msg `source` and `target` are the same object `source` és `target`: ugyanaz az objektum +@msg `source` is the only path in series group `target` `source`: az egyetlen útvonal ebben a sorozatcsoportban: `target` +@msg `source` must be `folder` or `datasets` `source`: `folder` vagy `datasets` kell +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing`: ezzel több mint {MAX_COUNT} vonal jutna a keretre +@msg `spacing` must be a number `spacing`: szám kell +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing`: pontban megadott, 0 vagy nagyobb szám, vagy "auto" kell +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing`: 0-nál nagyobb és legfeljebb {MAX_DISTANCE} pt érték kell +@msg `start` must be a finite number `start`: véges szám kell +@msg `starts` must be an array of anchor indices (or nulls) `starts`: horgonypont-indexek (vagy null értékek) tömbje kell +@msg `status` must be linked, embedded, missing or modified, or a list of them `status`: linked, embedded, missing vagy modified kell, vagy ezek listája +@msg `stop` must be a stop index `stop`: színátmenetpont-index kell +@msg `story` styles the whole story: give no `start` or `end` `story`: a teljes szövegfolyamot formázza; ne adj meg `start` vagy `end` értéket +@msg `story` takes the whole story: give no `start` or `end` `story`: a teljes szövegfolyamra vonatkozik; ne adj meg `start` vagy `end` értéket +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode`: körvonalakra vonatkozik (stroke: true) +@msg `style` must be roman or japanese `style`: roman vagy japanese kell +@msg `subpaths` must be two integer indices `subpaths`: két egész indexet kell megadni +@msg `symbol` must be a symbol name `symbol`: szimbólumnév kell +@msg `target` must be "object" or "contents", not {v} `target`: "object" vagy "contents" kell, ez nem az: {v} +@msg `target` must be an object of the same Objects on Path group as `member` `target`: ugyanannak az Objektumok útvonalon csoportnak egy objektuma kell, mint a `member` +@msg `tint` applies to global and spot colours `tint`: globális színekre és direktszínekre vonatkozik +@msg `tint` goes with a global or spot `swatch` `tint`: globális vagy direktszínű `swatch` értékkel együtt használható +@msg `tint` must be a number (%) `tint`: szám kell (%) +@msg `tint` needs a global or spot colour `tint`: globális szín vagy direktszín kell hozzá +@msg `to` is already threaded `to`: már láncolva van +@msg `to` is the design's new name `to`: a diagramminta új neve +@msg `to` must be [x, y] `to`: [x, y] kell +@msg `to` must be a closed path, area type or type on a path other than `from` `to`: zárt útvonal, területszöveg vagy útvonalszöveg kell, amely nem azonos ezzel: `from` +@msg `to` must be a whole number `to`: egész szám kell +@msg `to` takes the rows of one object's stack, not aligned rows `to`: egyetlen objektum halmának sorait fogadja, nem igazított sorokat +@msg `tolerance` must be from 0 to 100 pt `tolerance`: 0 és 100 pt közötti érték kell +@msg `useLab` must be true or false `useLab`: true vagy false kell +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign`: default, top, middle, baseline vagy bottom kell +@msg `values` must be a list `values`: lista kell +@msg `values` must be an object `values`: objektum kell +@msg `values` must name hex, rgb, cmyk, lab or gray `values`: ezek közül kell megneveznie: hex, rgb, cmyk, lab vagy gray +@msg `values` names a kind twice `values`: egy fajtát kétszer nevez meg +@msg `version` must be 0 or {_1} `version`: 0 vagy {_1} kell +@msg `version` must be 0 or {newest} `version`: 0 vagy {newest} kell +@msg `warichuAlign` must be one of {_1} `warichuAlign`: ezek egyike kell: {_1} +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines`: {_1} és {_2} közötti egész szám kell +@msg `weight` must be from 0.01 to 1000 pt `weight`: 0.01 és 1000 pt közötti érték kell +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights`: {_1} számból álló lista kell, soronként egy (kapott érték: {v}) +@msg `xml` must be a string `xml`: karakterlánc kell +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}`, {n}. bejegyzés: más behúzásokat ad a(z) {_1} objektum {start}. bájtjánál kezdődő bekezdésnek, mint egy korábbi bejegyzés +@msg `{KEY}` entry {n} must be an object `{KEY}`, {n}. bejegyzés: objektum kell +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}`, {n}. bejegyzés: sem a `leftIndent`, sem a `firstLineIndent` értéket nem állítja be +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}`, {n}. bejegyzés: `id`: objektumazonosító kell +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}`, {n}. bejegyzés: `start`: bájteltolás kell +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}`, {n}. bejegyzés: `{k}`: -{MAX_PARA} és {MAX_PARA} pt közötti szám kell +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}`, {n}. bejegyzés: a(z) {_1} objektumnak nincs a(z) {start}. bájtnál kezdődő bekezdése +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}`, {n}. bejegyzés: ismeretlen kulcs: `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}`, {n}. bejegyzés: nem szövegobjektum: {_1} +@msg `{KEY}` is empty `{KEY}`: üres +@msg `{KEY}` must be a list `{KEY}`: lista kell +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}`: legfeljebb {MAX_PARAGRAPH_INDENTS} bejegyzést fogad (kapott érték: {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}`: mellette nem adható meg ez: `{other}` +@msg `{_1}` can't be edited or removed nem szerkeszthető és nem távolítható el: `{_1}` +@msg `{_1}` is built in and always listed beépített elem, mindig szerepel a listán: `{_1}` +@msg `{_1}` is not a font file the last text.findFontFiles search found nem a legutóbbi text.findFontFiles keresés által talált betűtípusfájl: `{_1}` +@msg `{_1}` would be overwritten by the package: choose a different folder or name a csomag felülírná ezt: `{_1}`; válassz másik mappát vagy nevet +@msg `{base}` is larger than {_1} MB nagyobb, mint {_1} MB: `{base}` +@msg `{base}` is not in the DCS file's folder nincs a DCS-fájl mappájában: `{base}` +@msg `{file}` has no art to place nincs benne elhelyezhető grafika: `{file}` +@msg `{file}` is over {_1} MB nagyobb, mint {_1} MB: `{file}` +@msg `{f}` belongs to a VectorCraft that is running egy futó VectorCrafthoz tartozik: `{f}` +@msg `{f}` is the copy of a document open now egy most megnyitott dokumentum másolata: `{f}` +@msg `{id}` is no writable format (see document.formats) nem írható formátum: `{id}` (lásd: document.formats) +@msg `{id}` isn't a loaded library nem betöltött könyvtár: `{id}` +@msg `{key}` has no colours `{key}`: nincsenek benne színek +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}`: több mint {MAX_EDIT_WORDS} szót tartalmaz +@msg `{key}` is a design name `{key}`: diagramminta neve +@msg `{key}` is a design name or a list of them `{key}`: diagramminta neve vagy ilyen nevek listája +@msg `{key}` is longer than {MAX_NAME} characters `{key}`: hosszabb {MAX_NAME} karakternél +@msg `{key}` is out of range `{key}`: tartományon kívül esik +@msg `{key}` must be 0 or more `{key}`: 0 vagy nagyobb érték kell +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}`: [min, desired, max] vagy {{min?, desired?, max?}} kell, százalékban +@msg `{key}` must be [x, y] numbers `{key}`: [x, y] számpár kell +@msg `{key}` must be a list of words `{key}`: szavak listája kell +@msg `{key}` must be a list, not {v} `{key}`: lista kell, ez nem az: {v} +@msg `{key}` must be a non-negative integer `{key}`: nemnegatív egész szám kell +@msg `{key}` must be a number or null, not {v} `{key}`: szám vagy null kell, ez nem az: {v} +@msg `{key}` must be a number {_1}..{_2} `{key}`: szám kell, {_1}..{_2} +@msg `{key}` must be a whole number of 0 or more `{key}`: 0 vagy nagyobb egész szám kell +@msg `{key}` must be a whole number of at least 0, not {n} `{key}`: legalább 0 értékű egész szám kell, ez nem az: {n} +@msg `{key}` must be an appearance item index (paint order) or null `{key}`: megjelenési elem indexe (festési sorrend) vagy null kell +@msg `{key}` must be an array of asset ids (see assets.list) `{key}`: elemazonosítók tömbje kell (lásd: assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}`: 0 és 180 fok közötti érték kell, ez nem az: {a} +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}`: 0 és {MAX_DISTANCE} pt közötti érték kell +@msg `{key}` must be from {_1} to {shift_max} `{key}`: {_1} és {shift_max} közötti érték kell +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}`: {lo} és {hi} közötti érték kell, ez nem az: {v} +@msg `{key}` must be one of auto, always, never `{key}`: ezek egyike kell: auto, always, never +@msg `{key}` must be one of miter, round, bevel `{key}`: ezek egyike kell: miter, round, bevel +@msg `{key}` must be one word of at most {_1} characters `{key}`: egyetlen, legfeljebb {_1} karakteres szó kell +@msg `{key}` must hold single words of at most {_1} characters `{key}`: különálló, legfeljebb {_1} karakteres szavakat kell tartalmaznia +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}`: egy Objektumok útvonalon csoport útvonalához csatolt objektumot kell megneveznie +@msg `{key}` names an object that is hidden or locked `{key}`: rejtett vagy zárolt objektumot nevez meg +@msg `{key}`: not a colour: {v} `{key}`: nem szín: {v} +@msg `{k}.family` is required `{k}.family`: kötelező +@msg `{k}` is a number 0..1 `{k}`: szám, 0..1 +@msg `{k}` is true or false `{k}`: true vagy false +@msg `{k}` isn't an export setting: {_1} `{k}`: nem exportálási beállítás: {_1} +@msg `{k}` must be a byte offset `{k}`: bájteltolás kell +@msg `{k}` must be a number `{k}`: szám kell +@msg `{k}` must be a number of points `{k}`: pontban megadott szám kell +@msg `{k}` must be a string `{k}`: karakterlánc kell +@msg `{k}` must be a whole number `{k}`: egész szám kell +@msg `{k}` must be from {lo} to {hi} for {key} `{k}`: {lo} és {hi} közötti érték kell ehhez: {key} +@msg `{k}` must be {what}, not {v} `{k}`: ez kell: {what}; ez nem az: {v} +@msg `{l}` isn't a global or spot colour nem globális szín és nem direktszín: `{l}` +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText nem bitkép: `{m}`; használd a clipboard.importSvg, az importPdf vagy az importText parancsot +@msg `{name}` can't be deleted nem törölhető: `{name}` +@msg `{name}` can't be duplicated nem kettőzhető: `{name}` +@msg `{name}` can't be edited nem szerkeszthető: `{name}` +@msg `{name}` can't be edited this way így nem szerkeszthető: `{name}` +@msg `{name}` can't be renamed nem nevezhető át: `{name}` +@msg `{name}` can't name a folder nem nevezhet meg mappát: `{name}` +@msg `{name}` has no art to place nincs benne elhelyezhető grafika: `{name}` +@msg `{name}` has no text to place nincs benne elhelyezhető szöveg: `{name}` +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text a Word-dokumentumban lévő `{name}` nem UTF-8 vagy UTF-16 szöveg +@msg `{name}` in the Word document is not well-formed XML: {e} a Word-dokumentumban lévő `{name}` nem jól formált XML: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep a Word-dokumentumban lévő `{name}` elemei {MAX_NESTING} szintnél mélyebben vannak egymásba ágyazva +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` beépített előbeállítás, csak olvasható: mentsd más néven +@msg `{name}` is a built-in preset: save under another name `{name}` beépített előbeállítás: mentsd más néven +@msg `{name}` is a built-in profile and stays `{name}` beépített profil, megmarad +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` külön fájl, amely csak a DCS-fájl elérési útja mellett található meg +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` ennek a tónusa: `{base}`; ezt szerkeszd: `{base}` (egy tónusszínminta csak új nevet kaphat) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` munkaszíntér: előbb válassz másikat az edit.colorSettings paranccsal +@msg `{name}` is built in and stays `{name}` beépített elem, megmarad +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` csak a saját fajtájának megfelelő festést fogad (szín, színátmenet vagy mintázat) +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{n}` can't be deleted nem törölhető: `{n}` +@msg `{n}` can't be merged nem egyesíthető: `{n}` +@msg `{n}` can't be moved nem helyezhető át: `{n}` +@msg `{n}` is not a colour swatch nem színes színminta: `{n}` +@msg `{n}` isn't a solid colour nem egyszínű szín: `{n}` +@msg `{n}`: colour groups hold solid colours only `{n}`: a színcsoportok csak egyszínű színeket tartalmazhatnak +@msg `{other}` doesn't trace an image nem vektorizál képet: `{other}` +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: a lépés hivatkozásai több mint {_1} MB értéket másolnak be +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" nem tengelycímke: `{tag}`; a címke négy karakterből áll, például "wght" +@msg `{to}` is empty or already used üres vagy már használatban van: `{to}` +@msg `{u}` is no Unicode character nem Unicode-karakter: `{u}` +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}`: hosszabb {MAX_TEXT} bájtnál +@msg `{what}` must be text `{what}`: szöveg kell +@msg `{word}` isn't in the custom dictionary nincs az egyéni szótárban: `{word}` +@msg a CMap has too many mappings egy CMap túl sok leképezést tartalmaz +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard egy DXF-rajznak egy origója van: add meg a useArtboards: true értéket, ha rajztáblánként egy rajz kell +@msg a PDF presets file with over {_1} presets olyan PDF-előbeállításfájl, amelyben több mint {_1} előbeállítás van +@msg a \\u escape needs four hexadecimal digits a \\u escape-sorozathoz négy hexadecimális számjegy kell +@msg a batch preview cannot switch documents a kötegelt előnézet nem válthat dokumentumot +@msg a brush named `{nn}` exists már van ilyen nevű ecset: `{nn}` +@msg a comma in an XPath expression must separate function arguments XPath-kifejezésben a vessző csak függvényargumentumokat választhat el +@msg a container of the listed objects is gone a felsorolt objektumok egyik tárolója már nincs meg +@msg a content stream can't be read egy tartalomfolyam nem olvasható +@msg a content stream isn't a stream egy tartalomfolyam nem adatfolyam +@msg a design holds at most {_1} objects egy diagramminta legfeljebb {_1} objektumot tartalmazhat +@msg a design needs a name a diagrammintának név kell +@msg a dictionary grew too large egy szótár túl nagyra nőtt +@msg a document holds at most {_1} SVG filters egy dokumentum legfeljebb {_1} SVG-szűrőt tartalmazhat +@msg a document keeps at most {_1} graph designs egy dokumentum legfeljebb {_1} diagrammintát tárol +@msg a document keeps at most {_1} saved selections egy dokumentum legfeljebb {_1} mentett kijelölést tárol +@msg a document keeps at most {_1} views egy dokumentum legfeljebb {_1} nézetet tárol +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files egy dokumentum legfeljebb {MAX_SVG_SCRIPTS} JavaScript-fájlt csatolhat +@msg a document with unsaved changes has no recovery copy a mentetlen módosításokat tartalmazó dokumentumnak nincs helyreállítási másolata +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes egy feConvolveMatrix szűrő rendje {x}×{y}, ami túlcsordítja az usvg kernelméret-szorzását +@msg a font's data is too large egy betűtípus adatai túl nagyok +@msg a font's subroutines are too large egy betűtípus szubrutinjai túl nagyok +@msg a function declaration without a name név nélküli függvénydeklaráció +@msg a gradient preset is already called {n} már van ilyen nevű színátmenet-előbeállítás: {n} +@msg a graph has at most {_1} series, so it transposes with at most that many categories egy diagramnak legfeljebb {_1} adatsora lehet, ezért legfeljebb ennyi kategóriával cserélhető fel a sor és az oszlop +@msg a host value has too many entries egy gazdaérték túl sok elemet tartalmaz +@msg a host value is nested too deep egy gazdaérték túl mélyen van egymásba ágyazva +@msg a layer can't be merged into its own sublayer egy réteg nem egyesíthető a saját alrétegébe +@msg a layer's form box isn't an array egy réteg formaobjektumának kerete nem tömb +@msg a layer's form can't be read egy réteg formaobjektuma nem olvasható +@msg a layer's form has no box egy réteg formaobjektumának nincs kerete +@msg a layer's form has no dictionary egy réteg formaobjektumának nincs szótára +@msg a legend draws at most {MAX_CHIPS} swatches a jelmagyarázat legfeljebb {MAX_CHIPS} színmintát rajzol ki +@msg a name names one asset: give multiple: false, or one object egy név egy elemet nevez meg: adj meg multiple: false értéket, vagy egyetlen objektumot +@msg a namespace declaration uses a reserved prefix or URI egy névtér-deklaráció foglalt előtagot vagy URI-t használ +@msg a namespace qualifier is undefined egy névtérminősítő nincs definiálva +@msg a namespace with an empty URI cannot have a prefix üres URI-jú névtérnek nem lehet előtagja +@msg a number has no digits egy számban nincsenek számjegyek +@msg a number's exponent has no digits egy szám kitevőjében nincsenek számjegyek +@msg a number's fraction has no digits egy szám törtrészében nincsenek számjegyek +@msg a page can't be read egy oldal nem olvasható +@msg a page object is used by something else egy oldalobjektumot valami más is használ +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) az oldalbélyegkép RGB-képpontokból álljon, oldalanként 1 és {THUMBNAIL_SIZE} között (kapott méret: {w} × {h}) +@msg a pattern layer doesn't end what it starts egy mintázatréteg nem zárja le, amit megkezd +@msg a pattern layer's colors hold something else egy mintázatréteg színei mást tartalmaznak +@msg a pattern named `{n}` exists már van ilyen nevű mintázat: `{n}` +@msg a plug-in with id {_1} is already installed már telepítve van ilyen azonosítójú bővítmény: {_1} +@msg a preset named `{n}` exists már van ilyen nevű előbeállítás: `{n}` +@msg a print presets file with over {_1} presets olyan nyomtatási előbeállításfájl, amelyben több mint {_1} előbeállítás van +@msg a profile is already called {n} már van ilyen nevű profil: {n} +@msg a property descriptor cannot have both a value or writable and a get or set egy tulajdonságleíróban nem lehet egyszerre value vagy writable és get vagy set +@msg a property descriptor must be an object a tulajdonságleíró objektum kell legyen +@msg a property's {name} must be a function or undefined egy tulajdonság {name} értéke függvény vagy undefined lehet +@msg a row can't go into itself egy sor nem kerülhet önmagába +@msg a series goes on the left or the right value axis egy adatsor a bal vagy a jobb értéktengelyre kerül +@msg a start must be an anchor index ≥ 0 or null a kezdőpont ≥ 0 horgonypontindex vagy null lehet +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied egy benne lévő adatfolyam kibontva 256 MB-nál nagyobb lenne, vagy a dekódolási paraméterei nem alkalmazhatók +@msg a string holds a control character egy karakterlánc vezérlőkaraktert tartalmaz +@msg a string holds an escape sequence JSON does not have egy karakterlánc olyan escape-sorozatot tartalmaz, amely a JSON-ban nem létezik +@msg a string is not closed egy karakterlánc nincs lezárva +@msg a style named `{name}` already exists már van ilyen nevű stílus: `{name}` +@msg a style named `{to}` already exists már van ilyen nevű stílus: `{to}` +@msg a swatch needs a colour, gradient or pattern a színmintához szín, színátmenet vagy mintázat kell +@msg a symbol named `{n}` already exists már van ilyen nevű szimbólum: `{n}` +@msg a theme has 1 to {THEME_SIZE} colours egy témában 1–{THEME_SIZE} szín lehet +@msg a theme is already called {name} (replace: "{name}" overwrites it) már van ilyen nevű téma: {name} (a replace: "{name}" felülírja) +@msg a toolbar name has at most {MAX_NAME} characters az eszköztár neve legfeljebb {MAX_NAME} karakter lehet +@msg a view named `{_1}` exists már van ilyen nevű nézet: `{_1}` +@msg a view named `{name}` exists már van ilyen nevű nézet: `{name}` +@msg a workspace keeps at most {MAX} toolbars egy munkaterület legfeljebb {MAX} eszköztárat tárol +@msg a {_1} series can't go in a {_2} graph {_1} típusú adatsor nem kerülhet {_2} típusú diagramba +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-kompatibilis, ez az alapértelmezés) vagy "7" (régi .ai, 7-es verzió) +@msg already editing an opacity mask már folyik egy átlátszósági maszk szerkesztése +@msg already editing another object's opacity mask már folyik egy másik objektum átlátszósági maszkjának szerkesztése +@msg already in pattern editing mode már mintázatszerkesztési módban vagy +@msg an SVG filter is named by its id: give `xml` with `name` az SVG-szűrőt az azonosítója nevezi meg: a `name` mellé add meg az `xml` értéket +@msg an XML element has two attributes with the same name egy XML-elemnek két azonos nevű attribútuma van +@msg an XML value cannot be inserted into itself or its descendants egy XML-érték nem szúrható be önmagába vagy a leszármazottaiba +@msg an XML value's children cannot be assigned by position egy XML-érték gyermekei nem adhatók meg pozíció szerint +@msg an XML value's children cannot be deleted by position egy XML-érték gyermekei nem törölhetők pozíció szerint +@msg an XMLList converts to XML only when it has one item egy XMLList csak akkor alakítható XML-lé, ha egy eleme van +@msg an XPath node test is missing hiányzik egy XPath-csomópontteszt +@msg an XPath operand is missing hiányzik egy XPath-operandus +@msg an XPath operator is missing hiányzik egy XPath-operátor +@msg an XPath predicate or step cannot follow here itt nem következhet XPath-predikátum vagy -lépés +@msg an XPath predicate or step follows a value that is not a node-set egy XPath-predikátum vagy -lépés olyan érték után áll, amely nem csomóponthalmaz +@msg an XPath step is missing hiányzik egy XPath-lépés +@msg an artboard is listed twice egy rajztábla kétszer szerepel +@msg an empty artboard has no width or height to scale to üres rajztáblának nincs szélessége vagy magassága, amelyhez méretezni lehetne +@msg an image couldn't be decoded and is missing from the picture egy képet nem sikerült dekódolni, ezért hiányzik az ábráról +@msg an image is too large for DXF and was left out egy kép túl nagy a DXF-hez, ezért kimaradt +@msg an image of {_1} MB, at most {_2} MB a kép {_1} MB, legfeljebb {_2} MB lehet +@msg an image of {declared_w} × {declared_h} pixels is too large to draw egy {declared_w} × {declared_h} képpontos kép túl nagy a kirajzoláshoz +@msg an image of {width} × {height} pixels is too large to draw egy {width} × {height} képpontos kép túl nagy a kirajzoláshoz +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) egy {w} × {h} képpontos kép túl nagy az átalakításhoz (legfeljebb {_1} képpont) +@msg an index must be a whole number az index egész szám kell legyen +@msg an inline graphic has no glyphs egy szövegközi grafikának nincsenek glifái +@msg an invisible text form can't be read egy láthatatlan szöveg formaobjektuma nem olvasható +@msg an object is missing hiányzik egy objektum +@msg an object's header is missing hiányzik egy objektum fejléce +@msg an overprinting form can't be read egy felülnyomó formaobjektum nem olvasható +@msg an unread object has no readable bounds egy be nem olvasott objektumnak nincsenek olvasható határai +@msg an unread object holds a frame of threaded type egy be nem olvasott objektum láncolt szöveg keretét tartalmazza +@msg an unread object is missing from its layer egy be nem olvasott objektum hiányzik a rétegéből +@msg anchor `smooth` must be true or false horgonypont, `smooth`: true vagy false kell +@msg anchor `{key}` must be [x, y] horgonypont, `{key}`: [x, y] kell +@msg anchor `{key}` must be a number horgonypont, `{key}`: szám kell +@msg angle and roundness must be finite angle és roundness: véges érték kell +@msg angles must be a list as long as pins (degrees or null) angles: a pins listával azonos hosszúságú lista kell (fok vagy null) +@msg appearance must be flat|center|edge appearance: flat|center|edge kell +@msg apply is "fill" or "stroke", not `{other}` apply: "fill" vagy "stroke", ez nem az: `{other}` +@msg apply was called on a value that is not a function az apply hívása olyan értéken történt, amely nem függvény +@msg area must be a closed polygon of 3 to 100000 [x, y] points area: 3 és 100000 közötti számú [x, y] pontból álló zárt sokszög kell +@msg area type in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő területszöveg nem használhat dinamikus szöveget: {frames} +@msg area type needs a path that encloses an area a területszöveghez területet körülzáró útvonal kell +@msg area type {_1} is named twice a területszöveg kétszer szerepel: {_1} +@msg array literal too long a tömbliterál túl hosszú +@msg arrowAlign must be extend|tip, got {o} arrowAlign: extend|tip kell, kapott érték: {o} +@msg arrowScale must be [start, end] arrowScale: [start, end] kell +@msg artboard is a 0-based index artboard: 0-tól kezdődő index +@msg artboard must be a 0-based index artboard: 0-tól kezdődő index kell +@msg artboard {i} doesn't print ez a rajztábla nem kerül nyomtatásra: {i} +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout: {layout?, columns?, spacing?, rightToLeft?} kell +@msg artboardLayout.columns must be 1 or more artboardLayout.columns: 1 vagy nagyobb érték kell +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout: gridByRow, gridByColumn, row vagy column kell +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft: true vagy false kell +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing: 0–{MAX_SPACING} pt kell +@msg artboards must be a non-empty list artboards: nem üres lista kell +@msg artboards must be a number artboards: szám kell +@msg asset `{_1}` has no art to export ebben az elemben nincs exportálható grafika: `{_1}` +@msg assignment to a constant binding értékadás konstans kötésnek +@msg at least one row that isn't excluded needs a weight above 0 legalább egy ki nem zárt sornak 0-nál nagyobb súly kell +@msg at most {MAX_KEYWORDS} keywords legfeljebb {MAX_KEYWORDS} kulcsszó +@msg at most {MAX_MATCHES} matches are kept legfeljebb {MAX_MATCHES} találat marad meg +@msg at most {MAX_POINTS} points legfeljebb {MAX_POINTS} pont +@msg at most {MAX_QUEUE} files legfeljebb {MAX_QUEUE} fájl +@msg at must be [x, y] at: [x, y] kell +@msg at/rect lie outside the canvas at/rect: a vásznon kívül esik +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute: size|leading|tracking|kerning|baselineShift kell +@msg attrs must be an object attrs: objektum kell +@msg automation path rejected: the folder's name isn't valid text az automatizálás útvonala elutasítva: a mappa neve nem érvényes szöveg +@msg automation path rejected: {_1} isn't valid text az automatizálás útvonala elutasítva: nem érvényes szöveg: {_1} +@msg automation path rejected: {why}: {path} az automatizálás útvonala elutasítva: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis: "vertical", "horizontal" vagy fokban megadott szög kell, ez nem az: `{given}` +@msg backgroundContents must be transparent or white backgroundContents: transparent vagy white kell +@msg bad AES key hibás AES-kulcs +@msg bad `runs`: {e} hibás `runs`: {e} +@msg bad arrowScale hibás arrowScale +@msg bad base64 hibás base64 +@msg bad color {c} hibás szín: {c} +@msg bad colour hibás szín +@msg bad colour in `colors` hibás szín itt: `colors` +@msg bad colour {v} hibás szín: {v} +@msg bad colour {v} in groupColors hibás szín a groupColors értékben: {v} +@msg bad cross-reference entry hibás kereszthivatkozási bejegyzés +@msg bad cross-reference table hibás kereszthivatkozási tábla +@msg bad current colour {v} hibás jelenlegi szín: {v} +@msg bad dataBase64 hibás dataBase64 +@msg bad edit hibás szerkesztés +@msg bad fill {v} hibás kitöltés: {v} +@msg bad fit: {e} hibás illesztés: {e} +@msg bad model {other} (keep, new or document) hibás modell: {other} (keep, new vagy document) +@msg bad new colour {v} hibás új szín: {v} +@msg bad object hibás objektum +@msg bad object number hibás objektumszám +@msg bad offset hibás eltolás +@msg bad path data: {e} hibás útvonaladatok: {e} +@msg bad point {v}: expected [x, y] hibás pont: {v}; [x, y] kell +@msg bad point {v}: expected [x, y] or [x, y, pressure] hibás pont: {v}; [x, y] vagy [x, y, pressure] kell +@msg bad reference hibás hivatkozás +@msg bad trailer size hibás trailerméret +@msg bad {k} colour hibás szín: {k} +@msg batches cannot nest kötegek nem ágyazhatók egymásba +@msg bind something to a variable first előbb köss valamit egy változóhoz +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed: 0–{MAX_BLEED} pt kell: egy szám, [top, bottom, left, right] vagy {{top, bottom, left, right}} +@msg blend must be a mode name blend: keverési mód neve kell +@msg both sides of an XPath | must select nodes az XPath | mindkét oldalának csomópontokat kell kijelölnie +@msg brush must be a name or null brush: név vagy null kell +@msg by must be a number by: szám kell +@msg can't add page thumbnails: {why} nem adhatók hozzá oldalbélyegképek: {why} +@msg can't add the editing data for other apps: {why} nem adhatók hozzá a más alkalmazásoknak szánt szerkesztési adatok: {why} +@msg can't edit the written PDF: {why} a kiírt PDF nem szerkeszthető: {why} +@msg can't encrypt the PDF: {why} a PDF nem titkosítható: {why} +@msg can't linearise the PDF: {why} a PDF nem linearizálható: {why} +@msg can't open `{_1}`: {_2} nem nyitható meg: `{_1}`: {_2} +@msg can't open `{_1}`: {e} nem nyitható meg: `{_1}`: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) nem nyitható meg: `{name}`; ezt a formátumot a VectorCraft nem olvassa (lásd: document.formats) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) nem helyezhető el: `{name}`; ezt a formátumot a VectorCraft nem olvassa (lásd: document.formats) +@msg can't read back the written PDF: {e} a kiírt PDF nem olvasható vissza: {e} +@msg can't read this Word document: {why} ez a Word-dokumentum nem olvasható: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp .{e} képek nem írhatók: használd a .png, .jpg, .gif, .webp, .tif vagy .bmp formátumot +@msg can't write PDF layers, overprint or invisible text: {why} nem írhatók ki a PDF-rétegek, a felülnyomás vagy a láthatatlan szöveg: {why} +@msg can't write the file's copyright: {why} nem írható ki a fájl szerzői jogi adata: {why} +@msg can't write the file's modification date: {why} nem írható ki a fájl módosítási dátuma: {why} +@msg can't write the pages' user units: {why} nem írhatók ki az oldalak felhasználói egységei: {why} +@msg canceled megszakítva +@msg cannot convert the object to a primitive value az objektum nem alakítható primitív értékké +@msg cannot convert undefined or null to an object undefined vagy null nem alakítható objektummá +@msg cannot distort a zero-size or non-finite bounding box nulla méretű vagy nem véges határolókeret nem torzítható +@msg cannot move an object into itself egy objektum nem helyezhető át önmagába +@msg case must be upper|lower|title|sentence case: upper|lower|title|sentence kell +@msg category `{c}`: one of {_1} kategória `{c}`: ezek egyike: {_1} +@msg center must be [x, y] center: [x, y] kell +@msg character dimensions cannot be represented with the source text transform a karakterméretek nem ábrázolhatók a forrásszöveg transzformációjával +@msg character dimensions cannot be represented with the target text transform a karakterméretek nem ábrázolhatók a célszöveg transzformációjával +@msg choose at least one artboard válassz legalább egy rajztáblát +@msg choose at least one status válassz legalább egy állapotot +@msg clip must be true or false clip: true vagy false kell +@msg clippingMask must be true or false clippingMask: true vagy false kell +@msg closed must be a boolean closed: logikai érték kell +@msg collapsed must be true or false collapsed: true vagy false kell +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: nincs ilyen nevű RGB- vagy CMYK-profil: „{profile}” +@msg colorMode must be rgb or cmyk, not `{m}` colorMode: rgb vagy cmyk kell, ez nem az: `{m}` +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel: {own} (a dokumentum módja), grayscale vagy bitmap kell +@msg colour groups can't go into colour groups színcsoport nem kerülhet színcsoportba +@msg colour, mode and spot apply to solid-colour swatches only colour, mode és spot: csak egyszínű színmintákra vonatkozik +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read {column}. oszlop: `{cell}` a diagramadat-fájlok `%` oszlopa, ezek beolvasása nem történik meg +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable {column}. oszlop: `{cell}` ezt nevezi meg: `{name}`, amely {_1} típusú változó +@msg column {column}: `{cell}` names no variable {column}. oszlop: `{cell}` nem nevez meg változót +@msg column {column}: the variable name is longer than {MAX_NAME} characters {column}. oszlop: a változó neve hosszabb {MAX_NAME} karakternél +@msg column: true floats the whole column and takes no onto, above or below column: true az egész oszlopot lebegővé teszi, és nem adható meg mellette onto, above vagy below +@msg command must be a shape.* command command: shape.* parancs kell +@msg commands[{i}]: missing `command` commands[{i}]: hiányzik: `command` +@msg composer must be "singleLine" or "everyLine" composer: "singleLine" vagy "everyLine" kell +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes összetett alakzatba útvonalak, összetett útvonalak, csoportok, összetett alakzatok, szöveg, összemosások és burkolók kerülhetnek +@msg compound {_1} must contain exactly one open path to join az összetett objektumnak ({_1}) pontosan egy összekapcsolható nyitott útvonalat kell tartalmaznia +@msg connect needs a path other than the one extended connect: a meghosszabbított útvonaltól különböző útvonal kell +@msg connect needs id connect: id is kell hozzá +@msg connect path has no open end connect: az útvonalnak nincs nyitott vége +@msg continuation must be an array of path chunks continuation: útvonaldarabok tömbje kell +@msg continuation requires points continuation: points is kell hozzá +@msg copy needs path: the file to copy into the document's folder copy: path is kell hozzá: a dokumentum mappájába másolandó fájl +@msg copy takes images only: relink placed documents where their files are copy: csak képeket fogad; az elhelyezett dokumentumokat ott csatold újra, ahol a fájljaik vannak +@msg copying a file isn't available on the web a webes változatban nem lehet fájlt másolni +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus: unknown, copyrighted vagy publicDomain kell +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl: URL kell (szóköz nélkül, legfeljebb 2048 karakter) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners: 4 darab [x, y] pont kell (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item vagy file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: csak a PDF-oldalaknak van art, trim, bleed és media kerete; használd a "crop" vagy a "bounding" értéket +@msg crop rectangle doesn't overlap the image a vágási téglalap nem fedi a képet +@msg cross-reference and object streams aren't supported a kereszthivatkozási és objektumfolyamok nem támogatottak +@msg damaged compressed data: {e} sérült tömörített adatok: {e} +@msg dash lengths must be numbers, got {v} dash: a hosszak számok legyenek, kapott érték: {v} +@msg dash must be a list of lengths or null, got {v} dash: hosszak listája vagy null kell, kapott érték: {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) data kulcs `{k}`: betűk, számjegyek, -, _ és . (nem lehet name vagy vc-…) +@msg data must be an object of key: value, not {v} data: kulcs: érték párokból álló objektum kell, ez nem az: {v} +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare a(z) `{name}` adatkészlet értéket tartalmaz ehhez: `{var}`, de ezt a könyvtár nem deklarálja +@msg data set `{name}` holds two values for `{var}` a(z) `{name}` adatkészlet két értéket tartalmaz ehhez: `{var}` +@msg data set `{name}` is declared twice az adatkészlet kétszer van deklarálva: `{name}` +@msg data takes at most {MAX_DATA} keys at once data: egyszerre legfeljebb {MAX_DATA} kulcsot fogad +@msg dataset `{name}` already exists már van ilyen adatkészlet: `{name}` +@msg dataset `{to}` already exists már van ilyen adatkészlet: `{to}` +@msg date `{d}`: YYYY-MM-DD dátum `{d}`: YYYY-MM-DD +@msg decoded data is too large a dekódolt adatok túl nagyok +@msg define a variable first előbb definiálj egy változót +@msg detect must be true or false detect: true vagy false kell +@msg direct-select anchor points of a path jelöld ki közvetlen kijelöléssel egy útvonal horgonypontjait +@msg distance must be > 0 distance: > 0 érték kell +@msg dx and dy give the arrow's direction dx és dy: a nyíl irányát adják meg +@msg dx and dy must be numbers dx és dy: számok kellenek +@msg dxf fitTo must be [width, height], positive dxf fitTo: [width, height] kell, pozitív értékekkel +@msg dxf must be an object of DXF import options dxf: a DXF-importálási beállítások objektuma kell +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale: pozitív szám kell, legfeljebb {_1}; ez nem az: {s} +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: mértékegység kell, például mm, cm, in vagy pt +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} minden formátum egy {format, scale?, suffix?} objektum +@msg each item needs an id minden elemnek azonosító kell +@msg each match needs `kind` minden találathoz `kind` kell +@msg each match needs a `name` minden találathoz `name` kell +@msg each of `fonts` is {family, style?}; 1 to 1000 of them a `fonts` minden eleme {family, style?}; 1–1000 darab lehet +@msg each of `items` is {kind, item} az `items` minden eleme {kind, item} +@msg each of `items` is {{id, corners?}} with an object id, not {it} az `items` minden eleme {{id, corners?}} objektumazonosítóval, ez nem az: {it} +@msg each path chunk must contain 1 to 4096 points minden útvonaldarabnak 1–4096 pontot kell tartalmaznia +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) minden választás {{at: [x, y]}} vagy {{rect: [x0, y0, x1, y1]}} (kapott érték: {v}) +@msg each row needs `from` minden sorhoz `from` kell +@msg each stop needs a position minden színátmenetpontnak pozíció kell +@msg each subpath needs an anchors array minden részútvonalhoz anchors tömb kell +@msg each weight must be a number of 0 or more (got {v}) minden súly 0 vagy nagyobb szám legyen (kapott érték: {v}) +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType: easeIn, easeOut, easeInOut vagy easeOutIn kell +@msg editingData must be true or false editingData: true vagy false kell +@msg editingData must be true, false or "always" editingData: true, false vagy "always" kell +@msg edits overlap a szerkesztések átfedik egymást +@msg effects of objects whose appearances differ can't be moved together eltérő megjelenésű objektumok effektusai nem helyezhetők át együtt +@msg empty header in column {_1} üres fejléc ebben az oszlopban: {_1} +@msg encoding `{_1}`: utf8 or utf16 kódolás `{_1}`: utf8 vagy utf16 +@msg ends must be two of "first" and "last" ends: két érték kell ezekből: "first" és "last" +@msg every numbered name for the result is taken az eredmény minden számozott neve foglalt +@msg expanded SVG styles exceed the import size limit a kibontott SVG-stílusok túllépik az importálási méretkorlátot +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes az exportálási előbeállítások fájlja nagyobb, mint {MAX_PRESET_FILE_BYTES} bájt +@msg exportText must be editable or appearance exportText: editable vagy appearance kell +@msg exportType must be movieClip or graphic, not `{s}` exportType: movieClip vagy graphic kell, ez nem az: `{s}` +@msg extend needs id extend: id is kell hozzá +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges: legfeljebb {MAX_FEATURE_CHANGES} címkét sorolhat fel +@msg featureChanges must be a list of tags featureChanges: címkék listája kell +@msg features must be a list of tags features: címkék listája kell +@msg fidelity must be 0..100 fidelity: 0..100 kell +@msg files must be {name, dataBase64} files: {name, dataBase64} kell +@msg finally table {t} out of range a finally tábla tartományon kívül esik: {t} +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit: none|autoHeight|shrinkText|block kell, kapott érték: {id} +@msg flags cannot be given with a RegExp object RegExp objektum mellett nem adhatók meg jelzők +@msg flattener: {e} lapító: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low vagy egy mentett előbeállítás (lásd: flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile: along|across kell, kapott érték: {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item vagy itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels {MAX_BATCH_DEPTH} szintnél mélyebb mappák +@msg folders deeper than {MAX_DEPTH} levels {MAX_DEPTH} szintnél mélyebb mappák +@msg font family `{family}` is not available a betűcsalád nem érhető el: `{family}` +@msg font family `{tf}` is not available a betűcsalád nem érhető el: `{tf}` +@msg format `{f}`: pdf or postscript format `{f}`: pdf vagy postscript +@msg format `{f}`: text or json format `{f}`: text vagy json +@msg format is `{format}` a formátum: `{format}` +@msg formats must be an array of rows, not {v} formats: sorok tömbje kell, ez nem az: {v} +@msg frequency must be between 1 and 300 lines per inch frequency: 1 és 300 vonal/hüvelyk közötti érték kell +@msg from and to must be finite from és to: véges érték kell +@msg from must be [x0, y0, x1, y1] from: [x0, y0, x1, y1] kell +@msg function declaration index out of range a függvénydeklaráció indexe tartományon kívül esik +@msg give `before` or `after`, not both a `before` vagy az `after` értéket add meg, ne mindkettőt +@msg give `char`, `text`, `code` or `unicode` add meg a `char`, a `text`, a `code` vagy a `unicode` értéket +@msg give `colors`, or `color` and `rule` add meg a `colors` értéket, vagy a `color` és a `rule` értéket +@msg give `count` or `spacing`, not both a `count` vagy a `spacing` értéket add meg, ne mindkettőt +@msg give `event`, such as onclick add meg az `event` értéket, például onclick +@msg give `file`, the JavaScript file's path or URL add meg a `file` értéket: a JavaScript-fájl elérési útját vagy URL-jét +@msg give `fill` and/or `stroke` add meg a `fill` és/vagy a `stroke` értéket +@msg give `fill`, `stroke` or both add meg a `fill` vagy a `stroke` értéket, vagy mindkettőt +@msg give `folder` add meg a `folder` értéket +@msg give `fonts`, or open a document add meg a `fonts` értéket, vagy nyiss meg egy dokumentumot +@msg give `from` or `indices`, not both a `from` vagy az `indices` értéket add meg, ne mindkettőt +@msg give `id` (no text is being edited) add meg az `id` értéket (nincs szerkesztés alatt álló szöveg) +@msg give `id` and `anchors`, or `items`, not both az `id` és az `anchors` értéket, vagy az `items` értéket add meg, ne mindkettőt +@msg give `id` or `guide`, not both az `id` vagy a `guide` értéket add meg, ne mindkettőt +@msg give `length` or `to` add meg a `length` vagy a `to` értéket +@msg give `matches`: [{kind, name, target}] add meg a `matches` értéket: [{kind, name, target}] +@msg give `name` (or edit a pattern) add meg a `name` értéket (vagy szerkessz egy mintázatot) +@msg give `name` or `names` add meg a `name` vagy a `names` értéket +@msg give `name`, an SVG filter's name add meg a `name` értéket: egy SVG-szűrő nevét +@msg give `path` (a .wasm file) or `dataBase64` (the module) add meg a `path` (egy .wasm fájl) vagy a `dataBase64` (a modul) értéket +@msg give `path`, `data` or `dataBase64` add meg a `path`, a `data` vagy a `dataBase64` értéket +@msg give `path`, `data`, `dataBase64` or `csv` add meg a `path`, a `data`, a `dataBase64` vagy a `csv` értéket +@msg give `path`, `xml` or `dataBase64` add meg a `path`, az `xml` vagy a `dataBase64` értéket +@msg give `path`, an SVG file, or `svg`, SVG text add meg a `path` értéket (egy SVG-fájl) vagy az `svg` értéket (SVG-szöveg) +@msg give `script`, the JavaScript the event runs ("" removes the event) add meg a `script` értéket: az esemény által futtatott JavaScriptet (a "" eltávolítja az eseményt) +@msg give `text` (the reading) or `remove` add meg a `text` (az olvasat) vagy a `remove` értéket +@msg give `x` and `y` (and a `tolerance` of 0 or more) add meg az `x` és az `y` értéket (és egy 0 vagy nagyobb `tolerance` értéket) +@msg give `xml`, one SVG element add meg az `xml` értéket: egyetlen SVG elemet +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments adj meg egy módot, vagy r/g/b, c/m/y/k, gray vagy tint módosításokat +@msg give a name, or paint the active proxy with a saved preset adj meg egy nevet, vagy fesd az aktív mintadobozt egy mentett előbeállítással +@msg give a name, or select a stroke with a saved profile adj meg egy nevet, vagy jelölj ki egy mentett profilú körvonalat +@msg give a pattern name or select an object painted with a pattern adj meg egy mintázatnevet, vagy jelölj ki egy mintázattal festett objektumot +@msg give at least one of clip, invert, disabled, linked adj meg legalább egyet ezek közül: clip, invert, disabled, linked +@msg give color, none, swatch or gradient adj meg color, none, swatch vagy gradient értéket +@msg give dataBase64 add meg a dataBase64 értéket +@msg give either at (a click) or area (a path drawn around the overlaps) add meg az at (egy kattintás) vagy az area (az átfedések köré rajzolt útvonal) értéket +@msg give exactly one of `at`, `rect` and `points` pontosan egyet adj meg ezek közül: `at`, `rect`, `points` +@msg give exactly one of `path`, `data` and `dataBase64` pontosan egyet adj meg ezek közül: `path`, `data`, `dataBase64` +@msg give formats or a preset, not both a formats értéket vagy egy előbeállítást adj meg, ne mindkettőt +@msg give index or indexes, not both az index vagy az indexes értéket add meg, ne mindkettőt +@msg give newName or delete add meg a newName vagy a delete értéket +@msg give offset or by add meg az offset vagy a by értéket +@msg give one element egyetlen elemet adj meg +@msg give one of `to`, `rect` or `at` egyet adj meg ezek közül: `to`, `rect`, `at` +@msg give one of assets, layers, selection or fullDocument egyet adj meg ezek közül: assets, layers, selection, fullDocument +@msg give only one of onto, above, below, icon and a column place csak egyet adj meg ezek közül: onto, above, below, icon vagy egy oszlopbeli hely +@msg give opacity or blend add meg az opacity vagy a blend értéket +@msg give origin: [x, y], or reset: true add meg ezt: origin: [x, y], vagy ezt: reset: true +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note add meg az overprintFill, overprintStroke, showCenter, imageMap, url vagy note értéket +@msg give path (a file) or folder add meg a path (egy fájl) vagy a folder értéket +@msg give path, or name and dataBase64 add meg a path értéket, vagy a name és a dataBase64 értéket +@msg give paths or files add meg a paths vagy a files értéket +@msg give rows, rowHeight, columns or columnWidth add meg a rows, rowHeight, columns vagy columnWidth értéket +@msg give svg, or dataBase64 of an SVG or SVGZ file add meg az svg értéket, vagy egy SVG- vagy SVGZ-fájl dataBase64 értékét +@msg give text add meg a text értéket +@msg give the asset a name adj nevet az elemnek +@msg give the preset a `name` adj `name` értéket az előbeállításnak +@msg give the preset a name adj nevet az előbeállításnak +@msg give the preset a name (1–64 characters) adj nevet az előbeállításnak (1–64 karakter) +@msg give the settings to change, e.g. {"formats"}]} add meg a módosítandó beállításokat, például {"formats"}]} +@msg give the style a name adj nevet a stílusnak +@msg give two or more style `names` adj meg két vagy több stílusnevet a `names` értékben +@msg give two or more swatches adj meg két vagy több színmintát +@msg give x, y, or a handle's length or angle add meg az x, az y értéket, vagy egy fogantyú hosszát vagy szögét +@msg give {key, value} or {values} add meg ezt: {key, value} vagy {values} +@msg global must be true or false global: true vagy false kell +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) a(z) {_1} glifa nem ehhez a karakterhez rajzolódik ki a karakter stílusával: {ch} (ilyen például a Csupa nagybetű, a Kiskapitális vagy egy számrendszer) +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} a(z) {g} glifa nem alternatívája ennek: {ch} (betűtípus: {_1} {_2}) +@msg gradient must be "objects" or "mesh" gradient: "objects" vagy "mesh" kell +@msg grays must be "profile" or "black", not `{g}` grays: "profile" vagy "black" kell, ez nem az: `{g}` +@msg gridColors must be a preset name or two colours gridColors: előbeállítás neve vagy két szín kell +@msg gridSize must be small, medium or large gridSize: small, medium vagy large kell +@msg guide {g} is not selected a segédvonal nincs kijelölve: {g} +@msg handle must be 0..3 handle: 0..3 kell +@msg handler at {_1} expects {_2} values but its range starts with {d} a(z) {_1} helyen lévő kezelő {_2} értéket vár, de a tartománya ennyivel kezdődik: {d} +@msg handler depth too large a kezelő mélysége túl nagy +@msg handler range {_1}..{_2} out of range a kezelő tartománya ({_1}..{_2}) tartományon kívül esik +@msg hiddenLayers must be true or false hiddenLayers: true vagy false kell +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (ez az alapértelmezés), "omit" vagy "visible" +@msg highlight a layer to merge into emelj ki egy réteget, amelybe az egyesítés történjen +@msg highlight two or more rows in one layer or group emelj ki két vagy több sort egyetlen rétegben vagy csoportban +@msg highlight two or more rows to merge emelj ki két vagy több egyesítendő sort +@msg hvcC hvcC +@msg icon docks a panel in the icon column: use window.panel.dock icon: a panelt az ikonoszlopba dokkolja; használd a window.panel.dock parancsot +@msg id must be a dimension id id: méretvonal-azonosító kell +@msg image has no bounds a képnek nincsenek határai +@msg image {_1} is linked already a kép már csatolva van: {_1} +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client vagy server +@msg images in a spot ink whose samples couldn't be read were imported in RGB azok a direktszínű képek, amelyek mintái nem voltak olvashatók, RGB-ben lettek importálva +@msg images past the most pixels one file may hold were left out kimaradtak azok a képek, amelyek túllépték az egy fájlban megengedett legnagyobb képpontszámot +@msg images too large to read were left out a beolvasáshoz túl nagy képek kimaradtak +@msg index out of range az index tartományon kívül esik +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes: legfeljebb {MAX_GUIDE_INDEXES} elemet tartalmazhat +@msg indexes must be an array of guide indexes indexes: segédvonalindexek tömbje kell +@msg instances must be 1..1000 instances: 1..1000 kell +@msg instruction out of range az utasítás tartományon kívül esik +@msg instruction {pc} ({op}) pops below the stack a(z) {pc}. utasítás ({op}) a verem alja alól venne ki értéket +@msg instruction {pc} reached with stack depths {seen} and {d} a(z) {pc}. utasítás kétféle veremmélységgel érhető el: {seen} és {d} +@msg invalid `color` érvénytelen `color` +@msg invalid anchor index {v}: expected a non-negative integer érvénytelen horgonypontindex: {v}; nemnegatív egész szám kell +@msg invalid anchor reference {v}: expected [subpath, anchor] érvénytelen horgonypont-hivatkozás: {v}; [subpath, anchor] kell +@msg invalid angle érvénytelen szög +@msg invalid array length érvénytelen tömbhossz +@msg invalid brush: {e} érvénytelen ecset: {e} +@msg invalid color érvénytelen szín +@msg invalid colour {c} érvénytelen szín: {c} +@msg invalid delta érvénytelen eltérés +@msg invalid group érvénytelen csoport +@msg invalid object id {v}: expected a non-negative integer érvénytelen objektumazonosító: {v}; nemnegatív egész szám kell +@msg invalid point érvénytelen pont +@msg invalid regular expression flag '{_1}' érvénytelen jelző a reguláris kifejezésben: '{_1}' +@msg invalid regular expression: {m} érvénytelen reguláris kifejezés: {m} +@msg invalid size érvénytelen méret +@msg invalid t érvénytelen t +@msg invalid time value érvénytelen időérték +@msg invalid tolerance érvénytelen tűrés +@msg is a confirm's answer, whose `value` must be true or false egy megerősítés válasza, amelynek `value` értéke true vagy false lehet +@msg is a prompt's answer, whose `value` must be a string or null egy bekérés válasza, amelynek `value` értéke karakterlánc vagy null lehet +@msg is an alert's answer, whose `value` must be "ok" egy figyelmeztetés válasza, amelynek `value` értéke "ok" lehet +@msg is longer than 16 KiB hosszabb 16 KiB-nál +@msg it draws its image through a clipping path a képét vágóútvonalon keresztül rajzolja ki +@msg it draws no image nem rajzol ki képet +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes nagyobb, mint {MAX_DICTIONARY_FILE_BYTES} bájt +@msg it isn't a file nem fájl +@msg it names {_1} plates, and at most {MAX_PLATES} are read {_1} színkivonatot nevez meg, de legfeljebb {MAX_PLATES} olvasható be +@msg its art is too long or too detailed for a head a grafikája túl hosszú vagy túl részletes egy nyílhegyhez +@msg its art paints nothing a grafikája semmit sem fest +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) minden rajztáblája a vásznon kívül esik (több mint {_1} pt távolságra az origótól) +@msg its clipping masks are nested too deeply a vágómaszkjai túl mélyen vannak egymásba ágyazva +@msg its image is too large to read a képe túl nagy a beolvasáshoz +@msg its metadata has no description a metaadataiban nincs leírás +@msg its metadata has no direct length a metaadatainak nincs közvetlenül megadott hossza +@msg its plates are too large: more than {MAX_SAMPLES} pixels together a színkivonatai túl nagyok: együtt több mint {MAX_SAMPLES} képpont +@msg its plates are too large: {_1} plates of {w} × {h} pixels a színkivonatai túl nagyok: {_1} darab {w} × {h} képpontos színkivonat +@msg its plates can't be kept together a színkivonatai nem tarthatók együtt +@msg its shapes could not be combined into one az alakzatait nem sikerült eggyé egyesíteni +@msg join two different paths két különböző útvonalat kapcsolj össze +@msg jump to {pc}, past the end ugrás a végén túlra: {pc} +@msg justify must be auto|left|center|right|justifyAll justify: auto|left|center|right|justifyAll kell +@msg kbps must be a positive number, not {kbps} kbps: pozitív szám kell, ez nem az: {kbps} +@msg keywords must be a list of words or a comma-separated string keywords: szavak listája vagy vesszővel tagolt karakterlánc kell +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords: legfeljebb {MAX_KEYWORD} karakter lehet, vezérlőkarakterek nélkül +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind: 1, 2 vagy 3 kell, vagy adj meg egy előbeállítást a `name` értékben (lásd: perspective.presets.list) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind: graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle vagy paraStyle kell +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: "on", "off", "neutral" vagy logikai érték kell, ez nem az: {v} +@msg layer `{name}` has no art to export ebben a rétegben nincs exportálható grafika: `{name}` +@msg layer must name a layer or sublayer layer: réteget vagy alréteget kell megneveznie +@msg layers can't be intertwined: select the objects in them rétegek nem fonhatók össze: a bennük lévő objektumokat jelöld ki +@msg layers go only at the top level or in layers, objects in layers and groups rétegek csak a legfelső szintre vagy rétegekbe kerülhetnek, objektumok rétegekbe és csoportokba +@msg layers must be true or false layers: true vagy false kell +@msg layout must be gridByRow, gridByColumn, row or column layout: gridByRow, gridByColumn, row vagy column kell +@msg leading must be a number or "auto" leading: szám vagy "auto" kell +@msg left/right must be non-negative widths left/right: nemnegatív szélességek kellenek +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding: ezek egyike kell: {_1} +@msg level `{t}`: 2 or 3 level `{t}`: 2 vagy 3 +@msg line {line}: more than {MAX_CSV_COLS} columns {line}. sor: több mint {MAX_CSV_COLS} oszlop +@msg line {line}: unclosed quote {line}. sor: lezáratlan idézőjel +@msg line {line}: value for `{var}` must match its variable kind {line}. sor: a(z) `{var}` értékének meg kell felelnie a változó típusának +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf vagy crlf +@msg loading profiles from disk isn't available in the browser böngészőben nem lehet profilokat lemezről betölteni +@msg localize: the first argument must be a localization object or a string localize: az első argumentum lokalizációs objektum vagy karakterlánc kell legyen +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: {m} pt margó mellett nem marad nyomtatható terület a papíron +@msg matrix must be finite and invertible, depth finite matrix: véges és invertálható kell legyen, a depth véges +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member: az order front, forward, backward vagy back értékéhez való +@msg missing `artboards` hiányzik: `artboards` +@msg missing `at` [x, y] hiányzik: `at` [x, y] +@msg missing `attrs` hiányzik: `attrs` +@msg missing `color` or `opacity` hiányzik: `color` vagy `opacity` +@msg missing `colors` hiányzik: `colors` +@msg missing `commands` hiányzik: `commands` +@msg missing `destinationFolder` hiányzik: `destinationFolder` +@msg missing `effect` id hiányzik az `effect` azonosítója +@msg missing `family` hiányzik: `family` +@msg missing `find` hiányzik: `find` +@msg missing `gid` (a glyph id, or null for the default glyph) hiányzik: `gid` (glifaazonosító, vagy null az alapértelmezett glifához) +@msg missing `group` hiányzik: `group` +@msg missing `id` hiányzik: `id` +@msg missing `id`: give one, or select one type object hiányzik: `id`; adj meg egyet, vagy jelölj ki egy szövegobjektumot +@msg missing `index` (a point index, or null to clear) hiányzik: `index` (pontindex, vagy null a törléshez) +@msg missing `index` (a stop index, or null to clear) hiányzik: `index` (színátmenetpont-index, vagy null a törléshez) +@msg missing `index` (an item index or null) hiányzik: `index` (elemindex vagy null) +@msg missing `item` hiányzik: `item` +@msg missing `library` hiányzik: `library` +@msg missing `library` (see graphicStyle.libraries) hiányzik: `library` (lásd: graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) hiányzik: `library` (lásd: swatch.library.list) +@msg missing `map` (rows [{from, to}] or {current}) hiányzik: `map` ([{from, to}] sorok vagy {current}) +@msg missing `matrix` [a,b,c,d,e,f] hiányzik: `matrix` [a,b,c,d,e,f] +@msg missing `name` (see gradient.presets.list) hiányzik: `name` (lásd: gradient.presets.list) +@msg missing `offset` (a distance in points) hiányzik: `offset` (távolság pontban) +@msg missing `path` hiányzik: `path` +@msg missing `path` (a path object id) hiányzik: `path` (egy útvonalobjektum azonosítója) +@msg missing `picks` hiányzik: `picks` +@msg missing `rule` hiányzik: `rule` +@msg missing `source` id hiányzik a `source` azonosítója +@msg missing `start` (the byte offset of a character) hiányzik: `start` (egy karakter bájteltolása) +@msg missing `status`: linked, embedded, missing or modified hiányzik: `status`; linked, embedded, missing vagy modified lehet +@msg missing `target` id hiányzik a `target` azonosítója +@msg missing `text` hiányzik: `text` +@msg missing `to` hiányzik: `to` +@msg missing `to` (an index) hiányzik: `to` (egy index) +@msg missing `to`: a non-negative integer hiányzik: `to`; nemnegatív egész szám kell +@msg missing `type` hiányzik: `type` +@msg missing `value` hiányzik: `value` +@msg missing `word` hiányzik: `word` +@msg missing `{key}` hiányzik: `{key}` +@msg missing `{k}` hiányzik: `{k}` +@msg missing anchor hiányzik a horgonypont +@msg missing anchors hiányoznak a horgonypontok +@msg missing anchors [[subpath, anchor]…] hiányoznak a horgonypontok: [[subpath, anchor]…] +@msg missing array `{key}` hiányzik a tömb: `{key}` +@msg missing case hiányzik: case +@msg missing color hiányzik a szín +@msg missing commands hiányoznak a parancsok +@msg missing destination folder hiányzik a célmappa +@msg missing from hiányzik: from +@msg missing group hiányzik a csoport +@msg missing id or ids hiányzik: id vagy ids +@msg missing ids hiányzik: ids +@msg missing indexes hiányzik: indexes +@msg missing matrix [a,b,c,d,e,f] hiányzik: matrix [a,b,c,d,e,f] +@msg missing name hiányzik a név +@msg missing op hiányzik: op +@msg missing or invalid `color` hiányzó vagy érvénytelen `color` +@msg missing or unknown `tool` hiányzó vagy ismeretlen `tool` +@msg missing point hiányzik a pont +@msg missing point [x,y] hiányzik a pont: [x,y] +@msg missing point `{key}` ([x, y]) hiányzik a pont: `{key}` ([x, y]) +@msg missing points hiányoznak a pontok +@msg missing points [[x,y]…] hiányoznak a pontok: [[x,y]…] +@msg missing segment hiányzik a szakasz +@msg missing stops hiányoznak a színátmenetpontok +@msg missing subpaths hiányoznak a részútvonalak +@msg missing target hiányzik a cél +@msg missing to hiányzik: to +@msg missing x, y hiányzik: x, y +@msg mode is set, add or toggle, not `{other}` mode: set, add vagy toggle, ez nem az: `{other}` +@msg mode must be "cmyk" or "rgb" mode: "cmyk" vagy "rgb" kell +@msg mode must be area|onPath, got {o} mode: area|onPath kell, kapott érték: {o} +@msg more than 10000 linked files: package dependencies in smaller parts több mint 10000 csatolt fájl: kisebb részletekben csomagold a függőségeket +@msg more than 256 commands több mint 256 parancs +@msg more than {MAX_BATCH_FILES} data sets több mint {MAX_BATCH_FILES} adatkészlet +@msg more than {MAX_BATCH_FILES} files több mint {MAX_BATCH_FILES} fájl +@msg more than {MAX_CSV_ROWS} data rows több mint {MAX_CSV_ROWS} adatsor +@msg more than {MAX_FILES} images több mint {MAX_FILES} kép +@msg more than {MAX_IMPORT} presets több mint {MAX_IMPORT} előbeállítás +@msg moved must be [[x,y]…] moved: [[x,y]…] kell +@msg must be an object with `kind` and `value` `kind` és `value` kulcsot tartalmazó objektum kell +@msg must have `kind` alert, confirm or prompt a `kind` értéke alert, confirm vagy prompt kell legyen +@msg must have exactly `kind` and `value` pontosan a `kind` és a `value` kulcsot kell tartalmaznia +@msg name (a toolbar's name) is required name (egy eszköztár neve): kötelező +@msg name (a toolbar's name) must be a string name (egy eszköztár neve): karakterlánc kell +@msg name must be a toolbar's name name: egy eszköztár neve kell +@msg names is empty names: üres +@msg names must be an array of symbol names names: szimbólumnevek tömbje kell +@msg need `anchor` or `segment` + `t` `anchor` vagy `segment` + `t` kell +@msg need at least one anchor legalább egy horgonypont kell +@msg need at least one point legalább egy pont kell +@msg need at least two points legalább két pont kell +@msg needs a closing quote after the name a név után záró idézőjel kell +@msg needs a name név kell +@msg needs a name of at most 64 bytes legfeljebb 64 bájtos név kell +@msg needs a name of letters, digits and _, or a quoted name betűkből, számjegyekből és _ jelből álló név vagy idézőjelbe tett név kell +@msg needs a name without control characters vezérlőkarakterek nélküli név kell +@msg needs points pontok kellenek +@msg nesting too deep túl mély egymásba ágyazás +@msg nineSlice must be true or false nineSlice: true vagy false kell +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides: [left, top, right, bottom] kell, mindegyik -16..16, ahol left + right < 1 és top + bottom < 1 +@msg no HP-GL format nincs HP-GL formátum +@msg no New Document profile called `{shown}` ({_1}) nincs ilyen nevű új dokumentumprofil: `{shown}` ({_1}) +@msg no PDF preset named `{n}` nincs ilyen nevű PDF-előbeállítás: `{n}` +@msg no SVG filter at index {k} nincs SVG-szűrő ennél az indexnél: {k} +@msg no action set is current nincs aktuális műveletkészlet +@msg no active data set nincs aktív adatkészlet +@msg no appearance item {index} nincs ilyen megjelenési elem: {index} +@msg no arrowheads file here itt nincs nyílhegyfájl +@msg no artboard {_1} nincs ilyen rajztábla: {_1} +@msg no artboard {artboard} (0-based; the document has {_1}) nincs ilyen rajztábla: {artboard} (0-tól számozva; a dokumentumban {_1} van) +@msg no asset {id} nincs ilyen elem: {id} +@msg no asset {id} (see assets.list) nincs ilyen elem: {id} (lásd: assets.list) +@msg no assets to export nincs exportálható elem +@msg no assets yet: collect art for export first (assets.add) még nincsenek elemek: előbb gyűjts grafikát az exportáláshoz (assets.add) +@msg no brush named `{n}` nincs ilyen nevű ecset: `{n}` +@msg no character at byte {start} nincs karakter ennél a bájtnál: {start} +@msg no character has code {code} egyetlen karakternek sem ez a kódja: {code} +@msg no colour nincs szín +@msg no colour group `{group}` nincs ilyen színcsoport: `{group}` +@msg no colour group `{name}` nincs ilyen színcsoport: `{name}` +@msg no custom arrowhead {name} (see stroke.arrowheads.list) nincs ilyen egyéni nyílhegy: {name} (lásd: stroke.arrowheads.list) +@msg no data sets nincsenek adatkészletek +@msg no dataset named `{from}` nincs ilyen nevű adatkészlet: `{from}` +@msg no dataset named `{name}` nincs ilyen nevű adatkészlet: `{name}` +@msg no datasets nincsenek adatkészletek +@msg no design named `{name}` nincs ilyen nevű diagramminta: `{name}` +@msg no design named `{n}` nincs ilyen nevű diagramminta: `{n}` +@msg no design named `{old}` nincs ilyen nevű diagramminta: `{old}` +@msg no encoder for {_1} yet ehhez még nincs kódoló: {_1} +@msg no export preset `{name}` (see document.exportPresets.list) nincs ilyen exportálási előbeállítás: `{name}` (lásd: document.exportPresets.list) +@msg no file to open nincs megnyitandó fájl +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder nincs mappa: add meg a `path` értéket, vagy állítsd be ezt: Beállítások › Bővítmények & Munkalemezek › További bővítmények mappája +@msg no fonts to look for: they are all available nincs keresendő betűtípus: mindegyik elérhető +@msg no formats to export nincs exportálandó formátum +@msg no gradient preset `{name}` (see gradient.presets.list) nincs ilyen színátmenet-előbeállítás: `{name}` (lásd: gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` nincs ilyen grafikai stílus: `{n}` (itt: `{_1}`) +@msg no graphic style library `{key}` (see graphicStyle.libraries) nincs ilyen grafikaistílus-könyvtár: `{key}` (lásd: graphicStyle.libraries) +@msg no guide selected nincs kijelölt segédvonal +@msg no guide selected (or give an index) nincs kijelölt segédvonal (vagy adj meg egy indexet) +@msg no guide {i} nincs ilyen segédvonal: {i} +@msg no images in the folder a mappában nincsenek képek +@msg no instructions nincsenek utasítások +@msg no item at index {_1} nincs elem ennél az indexnél: {_1} +@msg no item at index {from} nincs elem ennél az indexnél: {from} +@msg no item at one of the indices {idx} az indexek egyikénél nincs elem: {idx} +@msg no layer nincs réteg +@msg no layer to rename nincs átnevezhető réteg +@msg no layer {id} (see layer.list) nincs ilyen réteg: {id} (lásd: layer.list) +@msg no layers to export nincs exportálható réteg +@msg no library `{key}` nincs ilyen könyvtár: `{key}` +@msg no metafile format nincs metafájlformátum +@msg no object {_1} nincs ilyen objektum: {_1} +@msg no objects nincsenek objektumok +@msg no parent layer nincs szülőréteg +@msg no perspective grid preset named `{b}` (see perspective.presets.list) nincs ilyen nevű perspektivikusrács-előbeállítás: `{b}` (lásd: perspective.presets.list) +@msg no perspective grid preset named `{n}` nincs ilyen nevű perspektivikusrács-előbeállítás: `{n}` +@msg no point selected (give `index`) nincs kijelölt pont (add meg az `index` értéket) +@msg no preset called `{from}` (see file.newPresets) nincs ilyen nevű előbeállítás: `{from}` (lásd: file.newPresets) +@msg no preset called `{name}` (see file.newPresets) nincs ilyen nevű előbeállítás: `{name}` (lásd: file.newPresets) +@msg no preset named `{n}` nincs ilyen nevű előbeállítás: `{n}` +@msg no print preset named `{b}` (see print.presets.list) nincs ilyen nevű nyomtatási előbeállítás: `{b}` (lásd: print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) nincs ilyen nevű nyomtatási előbeállítás: `{name}` (előbeállítások: {_1}) +@msg no print preset named `{n}` nincs ilyen nevű nyomtatási előbeállítás: `{n}` +@msg no profile named `{name}` nincs ilyen nevű profil: `{name}` +@msg no random numbers ({e}) nincsenek véletlenszámok ({e}) +@msg no readable files in the folder a mappában nincsenek olvasható fájlok +@msg no recovery copy `{f}` (see file.recovery.list) nincs ilyen helyreállítási másolat: `{f}` (lásd: file.recovery.list) +@msg no rows nincsenek sorok +@msg no saved PDF preset named `{name}` nincs ilyen nevű mentett PDF-előbeállítás: `{name}` +@msg no saved export preset `{name}` nincs ilyen mentett exportálási előbeállítás: `{name}` +@msg no saved gradient preset called {name} nincs ilyen nevű mentett színátmenet-előbeállítás: {name} +@msg no saved gradient presets nincs mentett színátmenet-előbeállítás +@msg no saved preset called `{name}` nincs ilyen nevű mentett előbeállítás: `{name}` +@msg no saved presets to export (name [Default] in `names` to export it) nincs exportálható mentett előbeállítás (az exportálásához nevezd meg a [Default] előbeállítást a `names` értékben) +@msg no saved presets to export (name built-in ones in `names`) nincs exportálható mentett előbeállítás (a beépítetteket a `names` értékben nevezd meg) +@msg no saved print preset named `{name}` nincs ilyen nevű mentett nyomtatási előbeállítás: `{name}` +@msg no saved profile called {name} nincs ilyen nevű mentett profil: {name} +@msg no saved selection `{name}` nincs ilyen mentett kijelölés: `{name}` +@msg no saved theme called {name} (see colorTheme.list) nincs ilyen nevű mentett téma: {name} (lásd: colorTheme.list) +@msg no saved views nincsenek mentett nézetek +@msg no selected or given object has a visible 3D effect egyik kijelölt vagy megadott objektumnak sincs látható 3D effektusa +@msg no series {i} nincs ilyen adatsor: {i} +@msg no slice {n} in the image (see slice.list) a képen nincs ilyen szelet: {n} (lásd: slice.list) +@msg no source art nincs forrásgrafika +@msg no stop {i} (the gradient has {n}) nincs ilyen színátmenetpont: {i} (a színátmenetnek {n} van) +@msg no style `{name}` nincs ilyen stílus: `{name}` +@msg no such artboard {v} nincs ilyen rajztábla: {v} +@msg no such assets (see assets.list) nincsenek ilyen elemek (lásd: assets.list) +@msg no such frame anchor, or the frame would reach past the canvas nincs ilyen kerethorgonypont, vagy a keret túlnyúlna a vásznon +@msg no such objects nincsenek ilyen objektumok +@msg no such path anchor, or the path would reach past the canvas nincs ilyen útvonal-horgonypont, vagy az útvonal túlnyúlna a vásznon +@msg no such rows nincsenek ilyen sorok +@msg no swatch `{keep}` nincs ilyen színminta: `{keep}` +@msg no swatch `{n}` nincs ilyen színminta: `{n}` +@msg no swatch `{n}` (swatches and colour groups move separately) nincs ilyen színminta: `{n}` (a színminták és a színcsoportok külön mozgathatók) +@msg no swatch library `{key}` (see swatch.library.list) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list; "document": a dokumentum színmintái) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list; "{DOCUMENT_SWATCHES}": a dokumentum színmintái) +@msg no swatch named `{n}` nincs ilyen nevű színminta: `{n}` +@msg no swatch or colour group `{n}` nincs ilyen színminta vagy színcsoport: `{n}` +@msg no swatch or colour group `{n}` in `{_1}` nincs ilyen színminta vagy színcsoport: `{n}` (itt: `{_1}`) +@msg no symbol named `{_1}` nincs ilyen nevű szimbólum: `{_1}` +@msg no symbol named `{name}` nincs ilyen nevű szimbólum: `{name}` +@msg no text objects selected nincs kijelölt szövegobjektum +@msg no toolbar named `{_1}` nincs ilyen nevű eszköztár: `{_1}` +@msg no toolbar named `{name}` nincs ilyen nevű eszköztár: `{name}` +@msg no tracing preset `{preset}` (see imageTrace.presets) nincs ilyen vektorizálási előbeállítás: `{preset}` (lásd: imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) itt nincs felhasználói könyvtármappa (ments elérési úttal, vagy anélkül, hogy megkapd az adatokat) +@msg no variable named `{from}` nincs ilyen nevű változó: `{from}` +@msg no variable named `{name}` nincs ilyen nevű változó: `{name}` +@msg no variable named `{var}` nincs ilyen nevű változó: `{var}` +@msg no variable named `{v}` nincs ilyen nevű változó: `{v}` +@msg no view named `{name}` nincs ilyen nevű nézet: `{name}` +@msg node {_1} already has the text variable `{_2}` a(z) {_1} csomópontnak már van szövegváltozója: `{_2}` +@msg node {_1} is not a graph nem diagram ez a csomópont: {_1} +@msg node {_1} is not an image nem kép ez a csomópont: {_1} +@msg node {_1} is not type nem szöveg ez a csomópont: {_1} +@msg not a DCS file nem DCS-fájl +@msg not a PDF presets file (format `{_1}`) nem PDF-előbeállításfájl (formátum: `{_1}`) +@msg not a PDF presets file: {e} nem PDF-előbeállításfájl: {e} +@msg not a RegExp object nem RegExp objektum +@msg not a Word document: its WordDocument stream doesn't start with a file information block nem Word-dokumentum: a WordDocument adatfolyama nem fájlinformációs blokkal kezdődik +@msg not a Word document: its compound file has no WordDocument stream nem Word-dokumentum: az összetett fájljában nincs WordDocument adatfolyam +@msg not a Word document: its main part is not a WordprocessingML document nem Word-dokumentum: a fő része nem WordprocessingML-dokumentum +@msg not a Word document: its package has no main document part nem Word-dokumentum: a csomagjában nincs fő dokumentumrész +@msg not a closed path nem zárt útvonal +@msg not a flattener presets file (format `{_1}`) nem lapítási előbeállításfájl (formátum: `{_1}`) +@msg not a flattener presets file: {e} nem lapítási előbeállításfájl: {e} +@msg not a layer or group nem réteg és nem csoport +@msg not a library file: {e} nem könyvtárfájl: {e} +@msg not a mesh envelope point nem hálós burkoló pontja +@msg not a perspective grid presets file (format `{_1}`) nem perspektivikusrács-előbeállításfájl (formátum: `{_1}`) +@msg not a perspective grid presets file: {e} nem perspektivikusrács-előbeállításfájl: {e} +@msg not a print presets file (format `{_1}`) nem nyomtatási előbeállításfájl (formátum: `{_1}`) +@msg not a print presets file: {e} nem nyomtatási előbeállításfájl: {e} +@msg not a regular expression nem reguláris kifejezés +@msg not a stored or deflated central record nem tárolt vagy deflate-tel tömörített központi rekord +@msg not a valid SVGZ file: {e} nem érvényes SVGZ-fájl: {e} +@msg not an RTF file: it doesn't start with {\\rtf nem RTF-fájl: nem ezzel kezdődik: {\\rtf +@msg not an action set: the text doesn't start with /version and a number nem műveletkészlet: a szöveg nem a /version szóval és egy számmal kezdődik +@msg not an image VectorCraft reads (see document.formats) nem olyan kép, amelyet a VectorCraft olvas (lásd: document.formats) +@msg not area type (text in a frame) outside perspective nem perspektíván kívüli területszöveg (keretben lévő szöveg) +@msg not editing an opacity mask nem folyik átlátszósági maszk szerkesztése +@msg not type on a path outside perspective nem perspektíván kívüli útvonalszöveg +@msg nothing prints semmi sem kerül nyomtatásra +@msg nothing to bind nincs mit kötni +@msg nothing to change nincs mit módosítani +@msg nothing to change: give dx, dy, hScale, vScale or rotation nincs mit módosítani: add meg a dx, dy, hScale, vScale vagy rotation értéket +@msg nothing to crop nincs mit körülvágni +@msg nothing to export nincs mit exportálni +@msg nothing to export: the document has no visible art nincs mit exportálni: a dokumentumban nincs látható grafika +@msg nothing to mark nincs mit megjelölni +@msg nothing to print: every artboard picked is blank nincs mit nyomtatni: minden kiválasztott rajztábla üres +@msg nothing to print: the document has no art that prints nincs mit nyomtatni: a dokumentumban nincs nyomtatásra kerülő grafika +@msg nothing to repeat nincs mit ismételni +@msg numbers out of order in {_1} quantifier a számok rossz sorrendben vannak ebben a kvantorban: {_1} +@msg object is not a path az objektum nem útvonal +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object az objektum átlátszósága nem vehető át kiemelt karakterekre; kapcsold ki az Átlátszóság beállítást a Pipetta beállításaiban, vagy jelöld ki a teljes objektumot +@msg object {_1} can't be set as point type ez az objektum nem állítható be pontszövegként: {_1} +@msg object {_1} has lost its file's contents: relink it ez az objektum elvesztette a fájlja tartalmát: {_1}; csatold újra +@msg object {_1} has no path ennek az objektumnak nincs útvonala: {_1} +@msg object {_1} has no size ennek az objektumnak nincs mérete: {_1} +@msg object {_1} is hidden or locked, or on a locked artboard ez az objektum rejtett vagy zárolt, vagy zárolt rajztáblán van: {_1} +@msg object {_1} is not a placed document ez az objektum nem elhelyezett dokumentum: {_1} +@msg object {_1} is not an Intertwine group ez az objektum nem összefonási csoport: {_1} +@msg object {_1} is not an image ez az objektum nem kép: {_1} +@msg object {_1} is not an image or a linked file ez az objektum nem kép és nem csatolt fájl: {_1} +@msg object {_1} is not an image or a placed document ez az objektum nem kép és nem elhelyezett dokumentum: {_1} +@msg object {_1} is not in this Intertwine group ez az objektum nincs ebben az összefonási csoportban: {_1} +@msg object {_1} needs a 3D effect ehhez az objektumhoz 3D effektus kell: {_1} +@msg object {_1} needs a 3D effect at index {index} a(z) {_1} objektumhoz 3D effektus kell ennél az indexnél: {index} +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic {_1} objektum: az üreges Kihúzás és élletörés effektusnak nincsenek fedőlapjai; grafika leképezéséhez kapcsold be a Fedőlap beállítást +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front {_1} objektum: 0 mélységnél az objektumnak egy lapja van; a grafikáit az előlapra képezd le +@msg object {_1}: {_2} {_1} objektum: {_2} +@msg object {_1}: {e} {_1} objektum: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams {_1} objektum: a(z) {index} indexű {name} effektusnak nincsenek megvilágítási előbeállításai; a fényeit az effect.setParams paranccsal állítsd be +@msg object {id} has anchors 0–{_1} for `corners`, not {past} a(z) {id} objektum horgonypontjai a `corners` értékhez: 0–{_1}; ilyen nincs: {past} +@msg object {id} has {n} anchors, so no anchor {past} a(z) {id} objektumnak {n} horgonypontja van, ezért ilyen nincs: {past} +@msg objects must be none, move or copy objects: none, move vagy copy kell +@msg objects were left out objektumok maradtak ki +@msg offset out of range az eltolás tartományon kívül esik +@msg on must be true or false on: true vagy false kell +@msg only groups, layers, type and images have a Contents (Characters) row csak a csoportoknak, a rétegeknek, a szövegnek és a képeknek van Tartalom (Karakterek) sora +@msg only interior mesh lines can be deleted csak belső hálóvonalak törölhetők +@msg only solid colours can be added (not gradients or patterns) csak egyszínű színek adhatók hozzá (színátmenetek és mintázatok nem) +@msg only solid-colour swatches can be spot colours csak egyszínű színminták lehetnek direktszínek +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható: a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt, vagy előbb exportálj SVG-t vagy PDF-et az Affinityből +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható; a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon és az oszlopbeli hely: a window.panel.float és a window.panel.dock parancshoz tartoznak +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon és az oszlopbeli hely: nem adható meg mellettük anchor, side, zone vagy before +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon és az oszlopbeli hely: nem adható meg mellettük x, y, width vagy height +@msg opacity must be a number opacity: szám kell +@msg open a document to look for its linked files a csatolt fájlok kereséséhez nyiss meg egy dokumentumot +@msg operand too large az operandus túl nagy +@msg options must be an object (see file.formatOptions) options: objektum kell (lásd: file.formatOptions) +@msg order is a whole number from 0 to {others}, not `{v}` order: 0 és {others} közötti egész szám, ez nem az: `{v}` +@msg order must be leftToRight or rightToLeft order: leftToRight vagy rightToLeft kell +@msg orientation must be portrait or landscape, not `{o}` orientation: portrait vagy landscape kell, ez nem az: `{o}` +@msg outlineText {v}: true or false outlineText {v}: true vagy false +@msg output intent profile: {e} kimeneti cél profilja: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: nincs ilyen nevű RGB- vagy CMYK-profil: „{name}” +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: a(z) {_1} fájlok CMYK színmódúak, „{name}” pedig RGB-profil +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: egyetlen festék sincs nyomtatásra állítva +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: nincs ilyen nevű profil: „{name}”, a(z) {std} kimeneti cél pedig beágyazza a profilját +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: nincs ilyen nevű profil: „{name}”; a(z) {std} kimeneti cél beágyaz egy profilt, vagy megnevez egy regisztrált nyomtatási feltételt (outputConditionId és registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: a(z) {std} fájlok CMYK-ban nyomtatódnak, „{name}” pedig RGB-profil +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: „{name}” szürke profil, amely nem lehet {std} kimeneti cél +@msg overlap: {o} pt is half a tile or more overlap: {o} pt egy fél csempe vagy annál több +@msg overlapping Shaper paths must be in the same layer or group az egymást átfedő Alakformáló-útvonalaknak ugyanabban a rétegben vagy csoportban kell lenniük +@msg overlapping edits egymást átfedő szerkesztések +@msg page art conflicts with image data for {key} az oldal grafikája ütközik ennek a képadataival: {key} +@msg page art conflicts with pattern {name} az oldal grafikája ütközik ezzel a mintázattal: {name} +@msg page art conflicts with swatch {name} az oldal grafikája ütközik ezzel a színmintával: {name} +@msg page art is missing image data for {key} az oldal grafikájából hiányoznak ennek a képadatai: {key} +@msg page art is missing pattern {name} az oldal grafikájából hiányzik ez a mintázat: {name} +@msg page art is missing swatch {name} az oldal grafikájából hiányzik ez a színminta: {name} +@msg page must be a whole number from 1 page: 1-től kezdődő egész szám kell +@msg page thumbnail: {e} oldalbélyegkép: {e} +@msg page {page} of the PDF has no objects a PDF {page}. oldalán nincsenek objektumok +@msg page {page}: `{file}` has {n} {artboards} {page}. oldal: `{file}` tartalma: {n} {artboards} +@msg page {page}: `{name}` has {n} {pages} {page}. oldal: `{name}` tartalma: {n} {pages} +@msg pages must be a range such as "2-3" or a page number, not {v} pages: tartomány (például "2-3") vagy oldalszám kell, ez nem az: {v} +@msg pages: {e} pages: {e} +@msg panel position must be finite a panel pozíciója véges érték kell legyen +@msg parameters must be an object parameters: objektum kell +@msg params must be an object params: objektum kell +@msg passwords can have up to {MAX_PASSWORD} bytes a jelszavak legfeljebb {MAX_PASSWORD} bájtosak lehetnek +@msg path chunks must share their boundary point az útvonaldarabok határpontjának közösnek kell lennie +@msg path has no open end az útvonalnak nincs nyitott vége +@msg paths must be strings paths: karakterláncok kellenek +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false esetén minden rajztábla kell ahhoz, hogy a szerkeszthető grafika megmaradjon +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false esetén a rajztáblák kellenek a szerkeszthető grafika megtartásához, ha a useArtboards értéke false +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single vagy multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom: "artwork" vagy "swatches", ez nem az: {v} +@msg pins and moved must be non-empty lists of the same length pins és moved: azonos hosszúságú, nem üres listák kellenek +@msg pins must be [[x,y]…] pins: [[x,y]…] kell +@msg pins must be finite pins: véges értékek kellenek +@msg place is above, below or inside, not `{other}` place: above, below vagy inside, ez nem az: `{other}` +@msg plane must be left|right|ground plane: left|right|ground kell +@msg plane must be left|right|ground|none plane: left|right|ground|none kell +@msg point is outside the mesh a pont a hálón kívül esik +@msg point x must be a number a pont x értéke szám kell legyen +@msg point y must be a number a pont y értéke szám kell legyen +@msg points must be [t, left, right] with non-negative widths points: [t, left, right] kell nemnegatív szélességekkel +@msg points must be [x,y] pairs points: [x,y] párok kellenek +@msg points must be a non-empty [[x,y,pressure?]…] list points: nem üres [[x,y,pressure?]…] lista kell +@msg points must be a non-empty list of finite [x,y] points: véges [x,y] értékek nem üres listája kell +@msg points must be an array or null points: tömb vagy null kell +@msg points must be finite points: véges értékek kellenek +@msg points must be finite [x,y] pairs points: véges [x,y] párok kellenek +@msg points must be finite canvas points points: véges vászonpontok kellenek +@msg points must contain 1–4096 coordinates points: 1–4096 koordinátát kell tartalmaznia +@msg position must be normal, superscript or subscript position: normal, superscript vagy subscript kell +@msg position must be topLeft, topRight, bottomLeft or bottomRight position: topLeft, topRight, bottomLeft vagy bottomRight kell +@msg positions must be between 0 and 100000 pt positions: 0 és 100000 pt közötti értékek kellenek +@msg ppi must be a positive number, not {ppi} ppi: pozitív szám kell, ez nem az: {ppi} +@msg ppi must be between 1 and 2400 ppi: 1 és 2400 közötti érték kell +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}`: nem transforms|bounds|fileDimensions|fit|fill +@msg preserveShape must be clippingMask|transparency preserveShape: clippingMask|transparency kell +@msg preset `{id}`: mobile or density preset `{id}`: mobile vagy density +@msg preset must be standard, diffuse, topLeft or right preset: standard, diffuse, topLeft vagy right kell +@msg preset must be the name of a print preset preset: egy nyomtatási előbeállítás neve kell +@msg previewMode must be default, pixel or overprint previewMode: default, pixel vagy overprint kell +@msg print settings: {e} nyomtatási beállítások: {e} +@msg procedures nest too deeply az eljárások túl mélyen ágyazódnak egymásba +@msg procedures nested too deeply az eljárások túl mélyen vannak egymásba ágyazva +@msg quotes must be {double?, single?} quotes: {double?, single?} kell +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k}: egy nyitó és egy záró idézőjel kell, például "“”" +@msg range out of order in character class rossz sorrendű tartomány a karakterosztályban +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi: 1–2400 kell (72 képernyő, 150 közepes, 300 magas) +@msg rating must be a whole number from 0 to {MAX_RATING} rating: 0 és {MAX_RATING} közötti egész szám kell +@msg rect and {k} contradict each other: give the area or the artboards rect és {k}: ellentmondanak egymásnak; a területet vagy a rajztáblákat add meg +@msg rect applies to raster formats, not {_1} rect: raszteres formátumokra vonatkozik, erre nem: {_1} +@msg rect is [x, y, width, height] in points rect: [x, y, width, height] pontban megadva +@msg rect must be [x, y, width, height] rect: [x, y, width, height] kell +@msg rect must be [x, y, width, height] with a positive width and height rect: [x, y, width, height] kell pozitív szélességgel és magassággal +@msg rect needs finite numbers and a positive width and height rect: véges számok, valamint pozitív szélesség és magasság kell +@msg redraw needs id redraw: id is kell hozzá +@msg redraw within must be a finite distance of 0 or more redraw within: 0 vagy nagyobb véges távolság kell +@msg reduce of an empty array with no initial value reduce hívása üres tömbön, kezdőérték nélkül +@msg registration must be 0..8 registration: 0..8 kell +@msg regular expression flag '{_1}' given twice a reguláris kifejezés jelzője kétszer szerepel: '{_1}' +@msg release a layer or a group réteget vagy csoportot oldj fel +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace: megtartja a lecserélt objektum helyét és transzformációját; hagyd el a template, at és rect értéket +@msg replace: select the one object to replace replace: jelöld ki azt az egy objektumot, amelyet le kell cserélni +@msg resolution must be a number or screen|medium|high resolution: szám vagy screen|medium|high kell +@msg resolution must be between 1 and 2400 ppi resolution: 1 és 2400 ppi közötti érték kell +@msg rotation must be a number of degrees rotation: fokban megadott szám kell +@msg rotation must be finite rotation: véges érték kell +@msg rows and cols must be 1..50 rows és cols: 1..50 kell +@msg rows and columns must be 1..500 rows és columns: 1..500 kell +@msg rows of objects whose appearances differ can't be reordered together eltérő megjelenésű objektumok sorai nem rendezhetők át együtt +@msg sampled text would exceed the canvas (coordinates out of range) a mintavételezett szöveg túllépné a vásznat (a koordináták tartományon kívül esnek) +@msg save the document first: Package collects a saved document előbb mentsd a dokumentumot: a Csomagolás mentett dokumentumot gyűjt össze +@msg save the document first: copy puts the file in its folder előbb mentsd a dokumentumot: a copy a dokumentum mappájába teszi a fájlt +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: szorzó (például 2x), szélesség (100w), magasság (100h) vagy felbontás (72ppi) kell +@msg scale must be a number above 0, at most 64 scale: 0-nál nagyobb, legfeljebb 64 értékű szám kell +@msg scale must be non-zero scale: nem lehet nulla +@msg scope must be selection or all (got {other}) scope: selection vagy all kell (kapott érték: {other}) +@msg scribble is too long a firka túl hosszú +@msg segment does not exist a szakasz nem létezik +@msg select a Shaper group jelölj ki egy alakformáló csoportot +@msg select a blend to set easing or color shift a gyorsítás vagy a színeltolás beállításához jelölj ki egy összemosást +@msg select a compound shape jelölj ki egy összetett alakzatot +@msg select a graph jelölj ki egy diagramot +@msg select a group or layer with visible, unlocked members jelölj ki egy csoportot vagy réteget, amelynek látható, nem zárolt tagjai vannak +@msg select a layer jelölj ki egy réteget +@msg select a text object jelölj ki egy szövegobjektumot +@msg select an SVG filter jelölj ki egy SVG-szűrőt +@msg select an image (or an Image Trace group) jelölj ki egy képet (vagy egy képvektorizálási csoportot) +@msg select an image, or pass id jelölj ki egy képet, vagy add meg az id értéket +@msg select an object (or pass `id`) jelölj ki egy objektumot (vagy add meg az `id` értéket) +@msg select an object with a 3D effect jelölj ki egy 3D effektussal rendelkező objektumot +@msg select an object with a 3D effect, or give its id in ids jelölj ki egy 3D effektussal rendelkező objektumot, vagy add meg az azonosítóját az ids értékben +@msg select an object with a freeform gradient jelölj ki egy szabad formájú színátmenettel rendelkező objektumot +@msg select an object with an opacity mask jelölj ki egy átlátszósági maszkkal rendelkező objektumot +@msg select area type jelölj ki területszöveget +@msg select area type (text in a frame) jelölj ki területszöveget (keretben lévő szöveget) +@msg select area type and closed paths (at least one area type object) jelölj ki területszöveget és zárt útvonalakat (legalább egy területszöveg-objektumot) +@msg select area type whose first line can fit its frame jelölj ki olyan területszöveget, amelynek az első sora elfér a keretében +@msg select artwork to add its colours jelölj ki grafikát a színei hozzáadásához +@msg select artwork to make a group of its colours jelölj ki grafikát, hogy a színeiből csoport készüljön +@msg select artwork to recolour, or give a colour `group` jelölj ki átszínezendő grafikát, vagy adj meg színcsoportot a `group` értékben +@msg select exactly two path ends to join pontosan két összekapcsolandó útvonalvéget jelölj ki +@msg select members of a compound shape jelöld ki egy összetett alakzat tagjait +@msg select objects or give ids jelölj ki objektumokat, vagy add meg az ids értéket +@msg select objects to align to the guide jelöld ki a segédvonalhoz igazítandó objektumokat +@msg select one Intertwine group egyetlen összefonási csoportot jelölj ki +@msg select one image or pass id egyetlen képet jelölj ki, vagy add meg az id értéket +@msg select one layer, group or object to rename egyetlen átnevezendő réteget, csoportot vagy objektumot jelölj ki +@msg select paths jelölj ki útvonalakat +@msg select paths or compound paths jelölj ki útvonalakat vagy összetett útvonalakat +@msg select rows or objects to collect jelöld ki az összegyűjtendő sorokat vagy objektumokat +@msg select something to export jelölj ki valamit az exportáláshoz +@msg select something to export (or turn off selectedOnly) jelölj ki valamit az exportáláshoz (vagy kapcsold ki a selectedOnly beállítást) +@msg select text to redefine the style from jelölj ki szöveget, amelyből a stílus újradefiniálható +@msg select the art and, on top of it, the mask object jelöld ki a grafikát és a fölötte lévő maszkobjektumot +@msg select the art for the design jelöld ki a diagrammintához szánt grafikát +@msg select the art for the pattern tile jelöld ki a mintacsempéhez szánt grafikát +@msg select the art to add first előbb jelöld ki a hozzáadandó grafikát +@msg select the art to collect (or give its ids) jelöld ki az összegyűjtendő grafikát (vagy add meg az azonosítóit) +@msg select the art to weed jelöld ki a gyomlálandó grafikát +@msg select the content and a path on top jelöld ki a tartalmat és egy fölötte lévő útvonalat +@msg select the images to relink, or pass ids jelöld ki az újracsatolandó képeket, vagy add meg az ids értéket +@msg select the images, or pass ids jelöld ki a képeket, vagy add meg az ids értéket +@msg select the linked images or placed documents, or pass ids jelöld ki a csatolt képeket vagy az elhelyezett dokumentumokat, vagy add meg az ids értéket +@msg select the object to copy from jelöld ki azt az objektumot, amelyről a másolás történjen +@msg select the objects (or give their ids) jelöld ki az objektumokat (vagy add meg az azonosítóikat) +@msg select the objects to attach to the path jelöld ki az útvonalhoz csatolandó objektumokat +@msg select the objects to bind jelöld ki a kötendő objektumokat +@msg select two or more objects to intertwine jelölj ki két vagy több összefonandó objektumot +@msg select two or more slices to combine jelölj ki két vagy több egyesítendő szeletet +@msg select type on a path jelölj ki útvonalszöveget +@msg select type on a path or area type jelölj ki útvonalszöveget vagy területszöveget +@msg selection `{m}`: one or each selection `{m}`: one vagy each +@msg settings is an object of document.exportForScreens params settings: a document.exportForScreens paramétereiből álló objektum +@msg settings must be an object settings: objektum kell +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified vagy embedded +@msg show must be true or false show: true vagy false kell +@msg side is left or right, not `{v}` side: left vagy right, ez nem az: `{v}` +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: "small", "medium", "large" vagy 0-nál nagyobb, legfeljebb {_1} pontérték kell +@msg size must be positive size: pozitív érték kell +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode: "fixed" vagy "pressure", ez nem az: `{other}` +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode: "fixed" vagy "pressure", ez nem az: {other} +@msg sizeVariation must be 0 or more sizeVariation: 0 vagy nagyobb érték kell +@msg smallCaps must be true or false smallCaps: true vagy false kell +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind vagy status +@msg source paths are too large for Shaper a forrásútvonalak túl nagyok az Alakformálóhoz +@msg source text transform is collapsed or unrepresentable a forrásszöveg transzformációja összeesett vagy nem ábrázolható +@msg spot, mode and group apply to solid colours only spot, mode és group: csak egyszínű színekre vonatkozik +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing vagy modified +@msg steps must be 1..1000 steps: 1..1000 kell +@msg steps must be 1..{MAX_STEPS} steps: 1..{MAX_STEPS} kell +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) {i}. színátmenetpont: `plates`: négy 0 és 1 közötti szám kell (C, M, Y, K) +@msg stopped before the archive was finished leállt, mielőtt az archívum elkészült volna +@msg strokeMode must be within|along|across, got {m} strokeMode: within|along|across kell, kapott érték: {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions: a stroke.set beállításaiból álló objektum kell +@msg strokeWidth must be a positive number of pixels strokeWidth: képpontban megadott pozitív szám kell +@msg subpath {si} is not an open subpath ez a részútvonal nem nyitott: {si} +@msg subpaths required when joining the same path ugyanazon útvonal összekapcsolásakor a subpaths kötelező +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict: "merge" vagy "add" kell, ez nem az: {v} +@msg table must be true or false table: true vagy false kell +@msg takes one name egy nevet fogad +@msg target text transform is collapsed or unrepresentable a célszöveg transzformációja összeesett vagy nem ábrázolható +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text: {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} kell +@msg text transform is collapsed or unrepresentable a szöveg transzformációja összeesett vagy nem ábrázolható +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces: 2 és 100 közötti egész szám kell +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: a "{_1}" vagy a "{_2}" értéket használd +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: add meg egy képfájl {path} vagy {name, dataBase64} értékét, vagy egy textúrát úgy, ahogy az effect.list megadja +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices ez több mint {MAX_PIECES} szeletet eredményezne +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer ez túl sok darabot (legfeljebb 100000) vagy szaggatási lépést eredményezne: egyszerűsítsd a szaggatási mintát, vagy növeld a vonalkák hosszát +@msg that object is not in the overlap ez az objektum nincs az átfedésben +@msg the Affinity document has no page with a size az Affinity-dokumentumban nincs mérettel rendelkező oldal +@msg the CMYK image couldn't be written a CMYK-képet nem sikerült kiírni +@msg the Layers panel is not shown a Rétegek panel nem látható +@msg the Layers panel's search, filter or Show Layers Only hides its row a Rétegek panel keresése, szűrője vagy a Csak rétegek megjelenítése beállítás elrejti a sorát +@msg the Objects on Path group is hidden or locked az Objektumok útvonalon csoport rejtett vagy zárolt +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected a PDF biztonsági beállításai a jelszavak nélkül lettek mentve: add meg újra az openPassword vagy a permissionsPassword értéket, vagy a védelem nélküli exportáláshoz a security értéket passwordRequired nélkül +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) az RTF-fájlban túl sok a bekezdés vagy a formázásváltás ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {MAX_PARAGRAPHS} bekezdés és {MAX_RUNS} szövegrész) +@msg the RTF file is too large to place as type (at most {_1} MB) az RTF-fájl túl nagy ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the SVG expands to more than {0} nested SVG images az SVG kibontva több mint {0} beágyazott SVG-képet tartalmaz +@msg the SVG expands to more than {0} objects az SVG kibontva több mint {0} objektumot tartalmaz +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes az SVG-fájl nagyobb, mint {MAX_IMPORT_BYTES} bájt +@msg the SVG has no drawable objects az SVG-ben nincsenek kirajzolható objektumok +@msg the SVGZ file unpacks to more than {_1} MB az SVGZ-fájl kicsomagolva nagyobb, mint {_1} MB +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place a Word-dokumentumban több mint {MAX_PAGES} oldalnyi tulajdonság van, ez túl sok az elhelyezéshez +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place a Word-dokumentumban több mint {MAX_RUNS} karakter- vagy bekezdéstulajdonság-szakasz van, ez túl sok az elhelyezéshez +@msg the Word document has more than {_1} MB of formatting properties, too many to place a Word-dokumentumban több mint {_1} MB formázási tulajdonság van, ez túl sok az elhelyezéshez +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) a Word-dokumentumban túl sok a szöveg vagy a formázás ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {MAX_PARAGRAPHS} bekezdés, {MAX_RUNS} szövegrész és {_1} MB szöveg) +@msg the Word document has too much text to place as type (at most {_1} MB) a Word-dokumentumban túl sok a szöveg ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it a Word-dokumentum titkosított vagy jelszóval védett; az elhelyezéshez mentsd jelszó nélkül +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) a Word-dokumentum túl nagy az elhelyezéshez (a(z) {name} adatfolyama több mint {_1} MB) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off a Word-dokumentum utoljára gyorsmentéssel lett mentve, amelyet a VectorCraft nem olvas; mentsd újra kikapcsolt gyorsmentéssel +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document a Word-dokumentumot a Word 6.0 vagy a Word 95 mentette, ezt a VectorCraft nem olvassa; mentsd Word 97-2003 dokumentumként +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place a Word-dokumentum szövege több mint {MAX_PIECES} darabból áll, ez túl sok az elhelyezéshez +@msg the XML operation needs an XML or XMLList value az XML-művelethez XML vagy XMLList érték kell +@msg the XPath expression does not evaluate to a node-set az XPath-kifejezés eredménye nem csomóponthalmaz +@msg the XPath expression ends early az XPath-kifejezés túl korán ér véget +@msg the XPath expression has an unmatched ) az XPath-kifejezésben pár nélküli ) van +@msg the XPath expression has an unmatched ] az XPath-kifejezésben pár nélküli ] van +@msg the XPath expression has an unmatched closing bracket az XPath-kifejezésben pár nélküli záró zárójel van +@msg the XPath expression selects a namespace node, which is not an XML value az XPath-kifejezés névtércsomópontot jelöl ki, amely nem XML-érték +@msg the XPath expression selects the root node, which is not an XML value az XPath-kifejezés a gyökércsomópontot jelöli ki, amely nem XML-érték +@msg the XPath function {name} needs a node-set ehhez az XPath-függvényhez csomóponthalmaz kell: {name} +@msg the XPath function {name} takes the wrong number of arguments ez az XPath-függvény nem megfelelő számú argumentumot kapott: {name} +@msg the XPath name functions need a node-set az XPath névfüggvényeihez csomóponthalmaz kell +@msg the XPath prefix {shown} is not bound to a namespace ez az XPath-előtag nincs névtérhez kötve: {shown} +@msg the XPath variable ${shown} is not bound ez az XPath-változó nincs kötve: ${shown} +@msg the action set ids are not a list a műveletkészlet-azonosítók nem alkotnak listát +@msg the active paint is not a gradient az aktív festés nem színátmenet +@msg the argument list is not an object az argumentumlista nem objektum +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size a rajztáblák nem férnek el a vásznon: kevesebb rajztábla, kisebb térköz vagy kisebb méret kell +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out a grafika túl nagy az EPS-hez: ami 256 MB PostScript után következik, az kimaradt +@msg the bitmaps hold more pixels than one file may a bitképek több képpontot tartalmaznak, mint amennyi egy fájlban megengedett +@msg the character's size can't be measured a karakter mérete nem mérhető meg +@msg the code runs past its last instruction a kód túlfut az utolsó utasításán +@msg the compound shape is too complex to expand az összetett alakzat túl bonyolult a kibontáshoz +@msg the container of the listed objects is gone a felsorolt objektumok tárolója már nincs meg +@msg the copy `{user_id}` can't be read as a library a másolat nem olvasható be könyvtárként: `{user_id}` +@msg the copy would leave the canvas a másolat kikerülne a vászonról +@msg the curve tolerance must be from 0.01 to 10 points a görbetűrés 0,01 és 10 pont között lehet +@msg the custom dictionary couldn't be saved to {_1}: {e} az egyéni szótárat nem sikerült ide menteni: {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is az egyéni szótár nem olvasható: {_1} ({why}), ezért változatlan marad +@msg the cut cannot be made on this art: {e} a vágás ezen a grafikán nem végezhető el: {e} +@msg the data isn't an EMF or WMF picture az adatok nem EMF- vagy WMF-képet alkotnak +@msg the data isn't an EPS or PostScript picture az adatok nem EPS- vagy PostScript-képet alkotnak +@msg the deferred paste is no longer available a halasztott beillesztés már nem érhető el +@msg the design is too big to draw at every data point of this graph a diagramminta túl nagy ahhoz, hogy a diagram minden adatpontjánál kirajzolható legyen +@msg the design is too big to draw in every column of this graph a diagramminta túl nagy ahhoz, hogy a diagram minden oszlopában kirajzolható legyen +@msg the dialog it was opened over is no longer open a párbeszédablak, amely fölött megnyílt, már nincs nyitva +@msg the dictionary stack overflowed a szótárverem túlcsordult +@msg the document already has an SVG filter named {name} a dokumentumban már van ilyen nevű SVG-szűrő: {name} +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it a dokumentumban van egy másik ilyen nevű SVG-szűrő: {name}; használd az svgFilter.apply parancsot, vagy a szerkesztéséhez az svgFilter.define parancsot a `replace` értékkel +@msg the document has no SVG filter named {name} a dokumentumban nincs ilyen nevű SVG-szűrő: {name} +@msg the document has no SVG filter named {r} a dokumentumban nincs ilyen nevű SVG-szűrő: {r} +@msg the document has no color swatches a dokumentumban nincsenek színes színminták +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 a dokumentum színmintái {_1} színt tartalmaznak; a document színcsökkentés legfeljebb 255-öt fogad +@msg the editing data's hash has no place a szerkesztési adatok kivonatának nincs helye +@msg the entities the document declares expand to too much text a dokumentumban deklarált entitások túl sok szöveggé bomlanak ki +@msg the envelope's content has no bounds a burkoló tartalmának nincsenek határai +@msg the envelope's mesh is malformed a burkoló hálója hibás szerkezetű +@msg the file holds no `variableSets` element a fájlban nincs `variableSets` elem +@msg the file is larger than {_1} MB a fájl nagyobb, mint {_1} MB +@msg the file is too large a fájl túl nagy +@msg the file isn't valid SVG: {e} a fájl nem érvényes SVG: {e} +@msg the file names no plate a fájl nem nevez meg színkivonatot +@msg the file was updated or encrypted already a fájl már frissítve vagy titkosítva lett +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) a sor első karaktere előtt nincs karakter, amelyhez alávágható lenne: csak felfelé és lefelé mozdul (dy) +@msg the font {_1} is not available ez a betűtípus nem érhető el: {_1} +@msg the format has no file extension a formátumnak nincs fájlkiterjesztése +@msg the function's prototype is not an object a függvény prototípusa nem objektum +@msg the graphic can't be read: {e} a grafika nem olvasható: {e} +@msg the graphic is empty a grafika üres +@msg the graphic's data is damaged a grafika adatai sérültek +@msg the graphic's document has no artboard a grafika dokumentumában nincs rajztábla +@msg the guide is hidden or locked a segédvonal rejtett vagy zárolt +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them a(z) `{name}` változó `{cell}` fejléce a(z) `{other}` változóként olvasódna vissza: nevezd át az egyiket +@msg the image `{path}` isn't followed: {r} a kép nem lesz követve: `{path}`: {r} +@msg the image has no embedded pixels a képnek nincsenek beágyazott képpontjai +@msg the image is empty a kép üres +@msg the image is fully transparent a kép teljesen átlátszó +@msg the image is not on an artboard; pass rect a kép nincs rajztáblán; add meg a rect értéket +@msg the image's pixels are missing a kép képpontjai hiányoznak +@msg the image's pixels aren't in the document a kép képpontjai nincsenek a dokumentumban +@msg the image's pixels can't be read: {e} a kép képpontjai nem olvashatók: {e} +@msg the image's transparency doesn't fit it a kép átlátszósága nem illik a képhez +@msg the job would print more than {MAX_PAGES} pages a feladat több mint {MAX_PAGES} oldalt nyomtatna +@msg the job would print more than {_1} pages a feladat több mint {_1} oldalt nyomtatna +@msg the layers hold no art to mask a rétegekben nincs maszkolható grafika +@msg the layout moved az elrendezés elmozdult +@msg the library has a group `{name}` already a könyvtárban már van ilyen csoport: `{name}` +@msg the library has no group `{name}` a könyvtárban nincs ilyen csoport: `{name}` +@msg the library has no item `{item}` a könyvtárban nincs ilyen elem: `{item}` +@msg the library has no item `{other}` a könyvtárban nincs ilyen elem: `{other}` +@msg the library holds {MAX_GROUPS} groups already a könyvtár már {MAX_GROUPS} csoportot tartalmaz +@msg the library holds {MAX_ITEMS} items of this kind already a könyvtár már {MAX_ITEMS} ilyen fajta elemet tartalmaz +@msg the library's file is outside the read roots a könyvtár fájlja kívül esik az olvasási gyökereken +@msg the line is too long, or the paths it passes too many, to check a vonal túl hosszú, vagy túl sok útvonalon halad át az ellenőrzéshez +@msg the method needs an XML value a metódushoz XML-érték kell +@msg the name is empty a név üres +@msg the object has no fill, stroke or effect az objektumnak nincs kitöltése, körvonala vagy effektusa +@msg the object has no graphic style (pass `name`) az objektumnak nincs grafikai stílusa (add meg a `name` értéket) +@msg the objects don't match the plan az objektumok nem egyeznek a tervvel +@msg the open and permissions passwords must differ a megnyitási és a jogosultsági jelszónak különböznie kell +@msg the operand stack overflowed az operandusverem túlcsordult +@msg the page has no area az oldalnak nincs területe +@msg the page's type in an unread object's box isn't in its page art az oldal egy be nem olvasott objektum keretében lévő szövege nincs benne az oldal grafikájában +@msg the paint behind the proxy is not a gradient a mintadoboz mögötti festés nem színátmenet +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) a festés nem szabad formájú színátmenet (alkalmazz egyet a paint.editGradient {kind} paranccsal) +@msg the parameters or body do not form one function a paraméterek vagy a törzs nem alkotnak egyetlen függvényt +@msg the parent must be a layer or sublayer a szülő réteg vagy alréteg kell legyen +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it a beillesztett SVG-szűrő ({name}) másképp rajzol, mint a dokumentum azonos nevű szűrője, és nem jut neki szabad név +@msg the path can't be extended az útvonal nem hosszabbítható meg +@msg the path can't be inside one of the objects to attach az útvonal nem lehet az egyik csatolandó objektum belsejében +@msg the path has no segments to measure az útvonalnak nincs mérhető szakasza +@msg the path is empty az útvonal üres +@msg the path is too short to shorten that much az útvonal túl rövid ahhoz, hogy ennyivel rövidíthető legyen +@msg the paths are too many or too tangled to find where they cross túl sok az útvonal, vagy túlságosan összegabalyodtak ahhoz, hogy a metszéspontjaik megtalálhatók legyenek +@msg the pen table holds more than 255 pens a tolltábla több mint 255 tollat tartalmaz +@msg the picture has no objects az ábrán nincsenek objektumok +@msg the plates can't be kept a színkivonatok nem tarthatók meg +@msg the position table does not match the instructions a pozíciótábla nem egyezik az utasításokkal +@msg the program runs too long a program túl sokáig fut +@msg the program uses too much memory a program túl sok memóriát használ +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes a bekérés szövege hosszabb {MAX_PROMPT_BYTES} bájtnál +@msg the radix must be between 2 and 36 a számrendszer alapja 2 és 36 között lehet +@msg the rectangle must have a width and a height a téglalapnak szélesség és magasság kell +@msg the rectangle needs a size and must lie on the canvas a téglalapnak méret kell, és a vásznon kell lennie +@msg the rectangle or line reaches too many paths to check a téglalap vagy a vonal túl sok útvonalat ér el az ellenőrzéshez +@msg the right side of 'in' is not an object az 'in' jobb oldala nem objektum +@msg the right side of 'instanceof' is not a function az 'instanceof' jobb oldala nem függvény +@msg the selected art has no width a kijelölt grafikának nincs szélessége +@msg the selected art is nested more than {MAX_DEPTH} levels deep a kijelölt grafika {MAX_DEPTH} szintnél mélyebben van egymásba ágyazva +@msg the selected art is too large to measure a kijelölt grafika túl nagy a méréshez +@msg the selection draws nothing a kijelölés semmit sem rajzol ki +@msg the selection has no area a kijelölésnek nincs területe +@msg the selection has no art to export a kijelölésben nincs exportálható grafika +@msg the selection has no bounds a kijelölésnek nincsenek határai +@msg the selection has no graphic style a kijelölésnek nincs grafikai stílusa +@msg the selection has no outlines to weed around a kijelölésnek nincs körvonala, amely körül gyomlálni lehetne +@msg the slice would leave the canvas a szelet kikerülne a vászonról +@msg the spot colour couldn't be added a direktszínt nem sikerült hozzáadni +@msg the subpath has no ends to extend (it is closed or a single point) a részútvonalnak nincs meghosszabbítható vége (zárt, vagy egyetlen pontból áll) +@msg the symbols are nested too deeply to expand their instances a szimbólumok túl mélyen vannak egymásba ágyazva a példányaik kibontásához +@msg the text file is too large to place as type (at most {_1} MB) a szövegfájl túl nagy ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the text is empty a szöveg üres +@msg the text is over {_1} MB a szöveg nagyobb, mint {_1} MB +@msg the top object must be a path a legfelső objektum útvonal kell legyen +@msg the trace was cancelled a vektorizálás megszakítva +@msg the trailer has no size a trailernek nincs mérete +@msg the type object is flattened to nothing a szövegobjektumból a lapítás után semmi sem marad +@msg the value bind was called on is not a function az érték, amelyen a bind hívása történt, nem függvény +@msg the value is not a constructor az érték nem konstruktor +@msg the value is not a function az érték nem függvény +@msg the value is not a valid XML name az érték nem érvényes XML-név +@msg the value is not an XML attribute name az érték nem XML-attribútumnév +@msg the value is not an XML name az érték nem XML-név +@msg the value of axis `{tag}` must be a number or null a(z) `{tag}` tengely értéke szám vagy null lehet +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets a változókönyvtár több mint {MAX_LIBRARY_DATASETS} adatkészletet deklarál +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities a változókönyvtár több mint {MAX_LIBRARY_ENTITIES} entitást deklarál +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables a változókönyvtár több mint {MAX_LIBRARY_VARIABLES} változót deklarál +@msg the variable library declares no variables a változókönyvtár nem deklarál változókat +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes a változókönyvtár nagyobb, mint {MAX_LIBRARY_BYTES} bájt +@msg the variable library is not UTF-8 a változókönyvtár nem UTF-8 kódolású +@msg the variable library is not well-formed XML: {e} a változókönyvtár nem jól formált XML: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep a változókönyvtár elemei {MAX_LIBRARY_NESTING} szintnél mélyebben vannak egymásba ágyazva +@msg the variable library's document type declaration holds something other than entities of plain text a változókönyvtár dokumentumtípus-deklarációja mást is tartalmaz, mint egyszerű szöveges entitásokat +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes a változókönyvtár entitásai kibontva nagyobbak, mint {MAX_LIBRARY_BYTES} bájt +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines a gyomlálóvonalak túl sokszor metszik a grafika körvonalait a méréshez; használj kevesebb vonalat +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap a gyomlálóvonalak túl sok körvonalszakaszhoz haladnak túl közel a méréshez; használj kevesebb vonalat vagy kisebb hézagot +@msg the weeding lines would be more than {max_pieces} pieces a gyomlálóvonalak több mint {max_pieces} darabból állnának +@msg the window has no dock az ablaknak nincs dokkja +@msg the written PDF has no catalog a kiírt PDF-nek nincs katalógusa +@msg the written PDF has no cross-reference table a kiírt PDF-nek nincs kereszthivatkozási táblája +@msg the written PDF's metadata can't be edited a kiírt PDF metaadatai nem szerkeszthetők +@msg the {_1} plate lies outside the file ez a színkivonat a fájlon kívül esik: {_1} +@msg there are no colour swatches to draw nincs kirajzolható színes színminta +@msg there is already a design named `{name}` már van ilyen nevű diagramminta: `{name}` +@msg there is already a design named `{to}` már van ilyen nevű diagramminta: `{to}` +@msg there is already a toolbar named `{name}` már van ilyen nevű eszköztár: `{name}` +@msg there is already a toolbar named `{to}` már van ilyen nevű eszköztár: `{to}` +@msg there is no library: create one first nincs könyvtár: előbb hozz létre egyet +@msg these paths are too degenerate for Shaper ezek az útvonalak túlságosan elfajultak az Alakformálóhoz +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that ez bináris DXF-fájl, amely nem olvasható: mentsd ASCII DXF-ként a CAD-alkalmazásból, és azt nyisd meg +@msg this warp's points can't be edited: use Reset with Mesh first ennek a hajlításnak a pontjai nem szerkeszthetők: előbb használd a Visszaállítás hálóval parancsot +@msg thumbnail: no page {v} (the {what} has {count}) bélyegkép: nincs ilyen oldal: {v} (a(z) {what} oldalszáma: {count}) +@msg tileRange of {what}: {e} tileRange ({what}): {e} +@msg title can't be empty title: nem lehet üres +@msg to (the new name) is required to (az új név): kötelező +@msg to must be fill or stroke to: fill vagy stroke kell +@msg to must be smooth or corner to: smooth vagy corner kell +@msg toExponential() digits must be between 0 and 20 toExponential(): a számjegyek száma 0 és 20 között lehet +@msg toFixed() digits must be between 0 and 20 toFixed(): a számjegyek száma 0 és 20 között lehet +@msg toISOString is not a function a toISOString nem függvény +@msg toPrecision() argument must be between 1 and 21 toPrecision(): az argumentum 1 és 21 között lehet +@msg toSource: a getter cannot be written as source toSource: getter nem írható ki forrásként +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: nem írható ki forrásként az a tömb, amelynek az elemein kívül tulajdonságai is vannak, nem felsorolható tulajdonsága van, vagy más a prototípusa +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: nem írható ki forrásként az az objektum, amelynek nem felsorolható tulajdonsága van, vagy a prototípusa nem Object.prototype +@msg toSource: the source is too long toSource: a forrás túl hosszú +@msg toSource: the value contains itself toSource: az érték önmagát tartalmazza +@msg toSource: the value has too many entries toSource: az érték túl sok elemet tartalmaz +@msg toSource: the value is nested too deep toSource: az érték túl mélyen van egymásba ágyazva +@msg toString is not a function a toString nem függvény +@msg too large for a zip archive: package to a folder túl nagy egy zip-archívumhoz: csomagolj mappába +@msg too many Shaper edits túl sok Alakformáló-szerkesztés +@msg too many `points` (at most 100000) túl sok `points` elem (legfeljebb 100000) +@msg too many arguments túl sok argumentum +@msg too many characters: at most {MAX_CHARS} túl sok karakter: legfeljebb {MAX_CHARS} +@msg too many dots for this size: lower the frequency túl sok raszterpont ehhez a mérethez: csökkentsd a vonalsűrűséget +@msg too many filters over one file túl sok szűrő egyetlen fájlon +@msg too many overlapping paths for Shaper túl sok egymást átfedő útvonal az Alakformálóhoz +@msg too many quantifiers túl sok kvantor +@msg too many saved clips túl sok mentett vágóútvonal +@msg too many saved graphics states túl sok mentett grafikus állapot +@msg toolbar (a toolbar's name) must be a string toolbar (egy eszköztár neve): karakterlánc kell +@msg toolbar name can't be empty az eszköztár neve nem lehet üres +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (ez az alapértelmezés) vagy "omit" +@msg two saved selections would be named `{_1}` két mentett kijelölés neve lenne ugyanaz: `{_1}` +@msg type dimension cannot be represented with this text transform a szövegméret nem ábrázolható ezzel a szövegtranszformációval +@msg type dimension must be finite a szövegméret véges érték kell legyen +@msg unexpected resources váratlan erőforrások +@msg unknown CMYK profile `{name}` ismeretlen CMYK-profil: `{name}` +@msg unknown File Info field `{k}` ismeretlen fájlinformációs mező: `{k}` +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) ismeretlen OpenType-funkció: `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; a - előtag kikapcsolja; a swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom és aalt egy alternatíva számát `tag=N` alakban fogadja, ahol N 0 és {_1} között lehet, például `salt=2`) +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) ismeretlen OpenType-funkció a featureChanges értékben: `{t}` (a `features` által fogadott címkék használhatók) +@msg unknown PDF preset `{name}` (presets: {_1}) ismeretlen PDF-előbeállítás: `{name}` (előbeállítások: {_1}) +@msg unknown Warp style ismeretlen hajlítási stílus +@msg unknown alignToPath `{a}` ismeretlen alignToPath: `{a}` +@msg unknown arrowhead {v} ismeretlen nyílhegy: {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) ismeretlen artboardLayout kulcs: `{k}` (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` ismeretlen attribútum: `{attribute}` +@msg unknown attribute `{k}` ismeretlen attribútum: `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) ismeretlen tengely: `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` ismeretlen keverési mód: `{b}` +@msg unknown blend mode {b} ismeretlen keverési mód: {b} +@msg unknown brush type `{o}` ismeretlen ecsettípus: `{o}` +@msg unknown category `{c}` ismeretlen kategória: `{c}` +@msg unknown category `{c}` ({_1}) ismeretlen kategória: `{c}` ({_1}) +@msg unknown character `{c}` ismeretlen karakter: `{c}` +@msg unknown character style number field ismeretlen karakterstílus-számmező +@msg unknown dither `{d}` (diffusion, pattern, noise or none) ismeretlen dither: `{d}` (diffusion, pattern, noise vagy none) +@msg unknown effect `{e}` ismeretlen effektus: `{e}` +@msg unknown end {e} ismeretlen end: {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) ismeretlen flattenerPreset: `{name}` (High Resolution, Medium Resolution, Low Resolution vagy egy mentett előbeállítás: lásd: flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) ismeretlen formátum: `{f}` (vcswatches, gpl, ase vagy css) +@msg unknown from {f} ismeretlen from: {f} +@msg unknown graph type `{t}` ismeretlen diagramtípus: `{t}` +@msg unknown grid colours `{name}` (one of {_1}) ismeretlen rácsszínek: `{name}` (ezek egyike kell: {_1}) +@msg unknown gridType `{t}` ismeretlen gridType: `{t}` +@msg unknown highlight `{v}` ismeretlen highlight: `{v}` +@msg unknown intent `{i}` ismeretlen intent: `{i}` +@msg unknown issue kind {id} ismeretlen problémafajta: {id} +@msg unknown issue kind {v} ismeretlen problémafajta: {v} +@msg unknown knockout state {k} ismeretlen kiütési állapot: {k} +@msg unknown language `{language}` (one of {_1}) ismeretlen nyelv: `{language}` (ezek egyike kell: {_1}) +@msg unknown language `{l}` (one of {_1}) ismeretlen nyelv: `{l}` (ezek egyike kell: {_1}) +@msg unknown method `{m}` ({_1}) ismeretlen módszer: `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) ismeretlen mód: `{other}` (gray, rgb, hsb, lab, cmyk vagy web) +@msg unknown mode `{other}` (mono|cmyk) ismeretlen mód: `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) ismeretlen mód: `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) ismeretlen mód: `{other}` (set, add, subtract) +@msg unknown mode `{o}` ismeretlen mód: `{o}` +@msg unknown mode {m} ismeretlen mód: {m} +@msg unknown model `{other}` ismeretlen modell: `{other}` +@msg unknown model `{other}` (keep, new or document) ismeretlen modell: `{other}` (keep, new vagy document) +@msg unknown op {o} ismeretlen op: {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back ismeretlen order: `{o}`; toggle, front, forward, backward vagy back lehet +@msg unknown overprints `{v}` (preserve, simulate or discard) ismeretlen overprints: `{v}` (preserve, simulate vagy discard) +@msg unknown panel ismeretlen panel +@msg unknown panel `{panel}` ismeretlen panel: `{panel}` +@msg unknown parameter `{key}` ismeretlen paraméter: `{key}` +@msg unknown parameter `{within}settings.{format}`; {why} ismeretlen paraméter: `{within}settings.{format}`; {why} +@msg unknown parameter `{within}{key}`; {why} ismeretlen paraméter: `{within}{key}`; {why} +@msg unknown pieLegend `{v}` (none, standard or wedges) ismeretlen pieLegend: `{v}` (none, standard vagy wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) ismeretlen piePosition: `{v}` (even, ratio vagy stacked) +@msg unknown pieSort `{v}` (none, all or first) ismeretlen pieSort: `{v}` (none, all vagy first) +@msg unknown plate `{n}` ismeretlen színkivonat: `{n}` +@msg unknown preference `{k}` ismeretlen beállítás: `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) ismeretlen profil: {id} (lásd: stroke.widthProfile.list) +@msg unknown proof target `{t}` ismeretlen proofcél: `{t}` +@msg unknown quotes key `{k}` (double, single) ismeretlen quotes kulcs: `{k}` (double, single) +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) ismeretlen színcsökkentés: `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform vagy document) +@msg unknown resolution `{v}` ismeretlen felbontás: `{v}` +@msg unknown rule `{rule}` ({_1}) ismeretlen szabály: `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) ismeretlen szabály: `{r}` (lásd: color.harmony) +@msg unknown setting `{k}` ismeretlen beállítás: `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) ismeretlen alakzat: `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) ismeretlen spacing: `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) ismeretlen tileType: `{t}` ({_1}) +@msg unknown units ismeretlen mértékegység +@msg unknown units `{u}` ismeretlen mértékegység: `{u}` +@msg unknown value axis `{v}` (left, right or both) ismeretlen értéktengely: `{v}` (left, right vagy both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) ismeretlen variáció: `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` ismeretlen hajlítási stílus: `{s}` +@msg unknown {key} `{v}` (none, short or full) ismeretlen {key}: `{v}` (none, short vagy full) +@msg unknown {key} profile `{n}` ismeretlen {key} profil: `{n}` +@msg unknown {k} key `{key}` (size, position) ismeretlen {k} kulcs: `{key}` (size, position) +@msg unmatched ')' pár nélküli ')' +@msg unreadable object olvashatatlan objektum +@msg unsafe destination file name nem biztonságos célfájlnév +@msg unterminated character class lezáratlan karakterosztály +@msg unterminated group lezáratlan csoport +@msg use paths or compound paths útvonalakat vagy összetett útvonalakat használj +@msg useArtboards must be true or false, not {v} useArtboards: true vagy false kell, ez nem az: {v} +@msg value for `{var}` must match its variable kind a(z) `{var}` értékének meg kell felelnie a változó típusának +@msg variable `{_1}` is already defined a változó már definiálva van: `{_1}` +@msg variable `{name}` has no `trait` a(z) `{name}` változónak nincs `trait` értéke +@msg variable `{name}` is already defined a változó már definiálva van: `{name}` +@msg variable `{name}` is declared twice a változó kétszer van deklarálva: `{name}` +@msg variable `{to}` already exists már van ilyen változó: `{to}` +@msg view is required a view kötelező +@msg view must be one of {_1} view: ezek egyike kell: {_1} +@msg warpStyle can only edit an applied Warp effect warpStyle: csak már alkalmazott Hajlítás effektust szerkeszthet +@msg width and height must be more than 0 and at most {limit} width és height: 0-nál nagyobb és legfeljebb {limit} érték kell +@msg width factors must be finite a szélességszorzók véges értékek legyenek +@msg width must be positive width: pozitív érték kell +@msg width out of range a szélesség tartományon kívül esik +@msg width/height must be > 0 width/height: > 0 érték kell +@msg wordGap and lineGap need area type in the selection wordGap és lineGap: területszöveg kell a kijelölésben +@msg x and y must be finite numbers x és y: véges számok kellenek +@msg x and y must be given together x és y: együtt kell megadni +@msg xpath needs its variables as an object xpath: a változóit objektumként kell megadni +@msg xpath needs the expression as a string xpath: a kifejezést karakterláncként kell megadni +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone: anchor is kell hozzá; dokkolj egy panel mellé az anchor értékkel, vagy új dokkoszlopba a side értékkel (Start vagy End) +@msg zoom must be positive zoom: pozitív érték kell +@msg {C}: no effect at index {list} {C}: nincs effektus ennél az indexnél: {list} +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} védett, megmarad +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} védett: mentsd a beállításokat más néven +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}, a beállításai és a gyorsítótára pedig nem helyezhetők vissza innen: {_1}, ide: {_2}: {e} +@msg {LABEL} produced an empty result {LABEL}: az eredmény üres +@msg {_1} MB of document, at most {_2} MB a dokumentum {_1} MB, legfeljebb {_2} MB lehet +@msg {_1} MB of images, at most {_2} MB a képek összesen {_1} MB, legfeljebb {_2} MB lehet +@msg {_1} bytes of RGBA data for a {width} × {height} image {_1} bájt RGBA-adat egy {width} × {height} méretű képhez +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1}: nem olvasható, vagy a beolvasása óta megváltozott; előbb csatold újra vagy frissítsd +@msg {_1} can't go into {into} {_1}: nem kerülhet ide: {into} +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1}: nem korlátozhatja a nyomtatást kis felbontásra; válassz PDF 1.4 vagy újabb verziót, vagy engedélyezd a nagy felbontású nyomtatást +@msg {_1} files can't be opened yet ezek a fájlok még nem nyithatók meg: {_1} +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1} fájlok nem védhetők jelszóval: távolítsd el a jelszavakat, vagy ne válassz szabványt +@msg {_1} files can't be {_2} {_1} fájlokra ez nem lehetséges: {_2} +@msg {_1} files can't carry editing data: turn off preserveEditing {_1} fájlok nem tartalmazhatnak szerkesztési adatokat: kapcsold ki a preserveEditing beállítást +@msg {_1} files can't have PDF layers: turn off createLayers {_1} fájlokban nem lehetnek PDF-rétegek: kapcsold ki a createLayers beállítást +@msg {_1} files need a creation date, and this platform has no clock to give one {_1} fájlokhoz létrehozási dátum kell, ezen a platformon pedig nincs óra, amely megadná +@msg {_1} is not a folder: {e} nem mappa: {_1}: {e} +@msg {_1} isn't a path nem útvonal: {_1} +@msg {_1} options: {e} {_1} beállításai: {e} +@msg {_1} paints nothing to rasterize {_1}: semmi raszterizálhatót nem fest +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1}: a jelszavak legfeljebb {MAX_LEGACY_PASSWORD} ASCII-karakterből állhatnak (a PDF 1.7 és az újabb verziók bármilyet elfogadnak) +@msg {_1} with linked images or an image map writes several files: use document.export {_1}: csatolt képekkel vagy képtérképpel több fájlt ír ki; használd a document.export parancsot +@msg {_1} writes one file per artboard here: use document.export, or name one artboard {_1}: itt rajztáblánként egy fájlt ír ki; használd a document.export parancsot, vagy nevezz meg egy rajztáblát +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} képpont túl nagy ehhez: {_3} (oldalanként legfeljebb {max} képpont); csökkentsd a léptéket +@msg {_1}: belongs to another account {_1}: másik fiókhoz tartozik +@msg {_1}: not a folder of its own {_1}: nem saját mappa +@msg {_1}: not a plain file {_1}: nem egyszerű fájl +@msg {_1}: {_2} and {_3} are in the way {_1}: útban van: {_2} és {_3} +@msg {code}: {0} {code}: {0} +@msg {dest}: {e} {dest}: {e} +@msg {dir}: {e} {dir}: {e} +@msg {doc} has no folder nincs mappája: {doc} +@msg {file} isn't linked nincs csatolva: {file} +@msg {file} names more than one linked JavaScript file {file}: egynél több csatolt JavaScript-fájlt nevez meg +@msg {folder} is not a package folder nem csomagmappa: {folder} +@msg {folder}: more than {max} files and folders {folder}: több mint {max} fájl és mappa +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group nem Élő festés csoport: {g} +@msg {id} is not a slice nem szelet: {id} +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key}: 0–{MAX_ADD_AROUND} pt kell +@msg {key} must be [x,y] {key}: [x,y] kell +@msg {key} must be a percentage {min}..100 {key}: százalék kell, {min}..100 +@msg {key} must be a positive number, not {n} {key}: pozitív szám kell, ez nem az: {n} +@msg {key} must be a positive number, not {v} {key}: pozitív szám kell, ez nem az: {v} +@msg {key} must be one of {_1} {key}: ezek egyike kell: {_1} +@msg {key} must be {_1}, got {s} {key}: ez kell: {_1}; kapott érték: {s} +@msg {key} must name a floating panel {key}: lebegő panelt kell megneveznie +@msg {key} must name a panel in another group {key}: másik csoportban lévő panelt kell megneveznie +@msg {key} must name a panel that shows {key}: látható panelt kell megneveznie +@msg {key} must name a registered panel {key}: regisztrált panelt kell megneveznie +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} fájlokban nincs átlátszóság, a fájlban viszont ez lenne: {what}; előbb lapítsd (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind} képek nem nyithatók meg (lásd: document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}`: nem a választási lehetőségek egyike +@msg {k} has control characters{_1} {k}: vezérlőkaraktereket tartalmaz{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k}: túl hosszú (legfeljebb {MAX_TEXT} karakter) +@msg {k} must be > 0 {k}: > 0 érték kell +@msg {k} must be a number (pt) or a length such as "210 mm" {k}: szám (pt) vagy hosszúság kell, például "210 mm" +@msg {k} must be a number from {min} to {max} {k}: {min} és {max} közötti szám kell +@msg {k} must be a number or "auto" {k}: szám vagy "auto" kell +@msg {k} must be a string {k}: karakterlánc kell +@msg {k} must be text {k}: szöveg kell +@msg {k} must be true or false {k}: true vagy false kell +@msg {k} must be {{size?, position?}} {k}: {{size?, position?}} kell +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: az egyéni nyílhegy útvonalának kitölthető terület kell, legfeljebb {most} horgonyponttal és {most} részútvonallal, és minden horgonypont legfeljebb {far} nyílhegyegységre lehet a csúcsától +@msg {k}: bad custom arrowhead path: {e} {k}: hibás egyéni nyílhegyútvonal: {e} +@msg {k}: bad custom arrowhead: {e} {k}: hibás egyéni nyílhegy: {e} +@msg {k}: can't read `{s}` as a length {k}: nem olvasható hosszúságként: `{s}` +@msg {k}: custom must be a name {k}: custom: név kell +@msg {label} must be a number at most {max} {label}: legfeljebb {max} értékű szám kell +@msg {label} must be a number from -1296 to 1296 {label}: -1296 és 1296 közötti szám kell +@msg {label} must be a number from 1 to 10000 {label}: 1 és 10000 közötti szám kell +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name}: beépített elem, nem törölhető +@msg {name} must be a number {name}: szám kell +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name}: {lo}–{hi}{unit} kell (kapott érték: {v}) +@msg {name}: CRC or size mismatch {name}: a CRC vagy a méret nem egyezik +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható; a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt, vagy az elhelyezés előtt exportálj SVG-t vagy PDF-et az Affinityből +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; a beágyazott előnézete sem olvasható ({e}) +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: a copy csak akkor fogad DCS-fájlt, ha a színkivonatai benne vannak; ott csatold újra, ahol a színkivonatfájljai vannak +@msg {path}: larger than {_1} MB {path}: nagyobb, mint {_1} MB +@msg {shown} is not an XPath 1.0 function nem XPath 1.0-függvény: {shown} +@msg {std} files are CMYK, and the file would have colours in {_1} {std} fájlok CMYK színmódúak, a fájlban viszont ilyen színek is lennének: {_1} +@msg {std} files embed their fonts, and the file would have one that isn't {std} fájlok beágyazzák a betűtípusaikat, a fájlban viszont lenne olyan, amely nincs beágyazva +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target}: ebből a fájlból lett beolvasva ez a dokumentum, és a beolvasás során egyes részek kimaradtak ({what}): a felülírásával ezek végleg elvesznének. Írj inkább másik fájlt, vagy add meg az acknowledgeLoss: true értéket, ha mégis le akarod cserélni +@msg {ty} brushes need selected art (or params.{k}) {ty} ecsetekhez kijelölt grafika kell (vagy params.{k}) +@msg {v} is not an asset id nem elemazonosító: {v} +@msg {v} names no layer or sublayer {v}: nem nevez meg réteget vagy alréteget +@msg {what} can't be intertwined nem fonható össze: {what} +@msg {what} doesn't fit the paper with its bleed and marks {what}: a kifutóval és a jelekkel együtt nem fér el a papíron +@msg {what} is not a function nem függvény: {what} +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what}: egy ívre több mint {MAX_COPIES_PER_SHEET} példány kerülne; növeld a méretét +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}: több mint {MAX_TILES} csempére kerülne nyomtatásra; csökkentsd a méretét +@msg {what} {i} out of range {what} {i}: tartományon kívül esik +@msg `{key}` must be from 0.01 to 1000 pt `{key}`: 0.01 és 1000 pt közötti érték kell +@msg select the banner, or mark every artboard jelöld ki a molinót, vagy jelöld meg az összes rajztáblát +@msg the banner has no width or height a molinónak nincs szélessége vagy magassága +@msg the grommet inset is too large for the banner a ringlik széltől mért távolsága túl nagy a molinóhoz +@msg the hem is too deep for the banner a szegés túl mély a molinóhoz +@msg the spacing makes more than {MAX_GROMMETS} grommets ezzel a térközzel több mint {MAX_GROMMETS} ringli keletkezne +@msg there is no banner to mark nincs megjelölhető molinó +@msg turn on at least one side kapcsolj be legalább egy oldalt +@msg with no hem and no grommets there is nothing to mark szegés és ringlik nélkül nincs mit megjelölni +@msg {saving} failed: {e} {saving}: sikertelen: {e} + +# 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 A 3D effektusokhoz 100 millió ponton belüli véges koordináták kellenek +@msg 3D effects require one to ten lights A 3D effektusokhoz egy és tíz közötti számú fény kell +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-színmélység {_1}: 1, 4, 8, 16, 24 vagy 32 bit/pixel lehet +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer A BMP-kódolás sikertelen: {width} × {height} pixel nem fér bele a formátumba vagy a pixelpufferbe +@msg GIF encoding failed: the palette image is malformed A GIF-kódolás sikertelen: a palettás kép hibás szerkezetű +@msg GIF encoding failed: {e} A GIF-kódolás sikertelen: {e} +@msg JPEG colour profile: {e} JPEG-színprofil: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size A JPEG-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg JPEG encoding failed: {e} A JPEG-kódolás sikertelen: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size A PNG-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg PNG encoding failed: the palette image is malformed A PNG-kódolás sikertelen: a palettás kép hibás szerkezetű +@msg PNG encoding failed: {e} A PNG-kódolás sikertelen: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size A PNG-kódolás sikertelen: {width} × {height} pixel nem érvényes képméret +@msg PSD colour profile: {e} PSD-színprofil: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size A PSD-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg Revolve profile contains invalid coordinates Forgatás tengely körül: a profil érvénytelen koordinátákat tartalmaz +@msg Revolve profile is too complex Forgatás tengely körül: a profil túl összetett +@msg Revolve projection is not finite Forgatás tengely körül: a vetület nem véges +@msg Revolve requires finite profile coordinates within 100 million points Forgatás tengely körül: 100 millió ponton belüli véges profilkoordináták kellenek +@msg Revolve surface is too complex; reduce Segments or simplify the profile Forgatás tengely körül: a felület túl összetett; csökkentsd a Szakaszok értékét, vagy egyszerűsítsd a profilt +@msg Revolve surface is too complex; simplify the profile Forgatás tengely körül: a felület túl összetett; egyszerűsítsd a profilt +@msg Revolve visibility cannot split the projected boundary Forgatás tengely körül: a láthatóság számítása nem tudja felosztani a vetített határvonalat +@msg Revolve visibility creates too many fragments Forgatás tengely körül: a láthatóság számítása túl sok darabot hoz létre +@msg Revolve visibility creates too many vector anchors Forgatás tengely körül: a láthatóság számítása túl sok vektoros horgonypontot hoz létre +@msg Revolve visibility exceeds its geometry work limit Forgatás tengely körül: a láthatóság számítása túllépi a geometriai számítások korlátját +@msg Revolve visibility exceeds its spatial index limit Forgatás tengely körül: a láthatóság számítása túllépi a térbeli index korlátját +@msg Revolve visibility has too many boundary crossings Forgatás tengely körül: a láthatóság számításában túl sok a határvonal-keresztezés +@msg Revolve visibility has too many input anchors Forgatás tengely körül: a láthatóság számításában túl sok a bemeneti horgonypont +@msg Revolve visibility requires convex cap sectors Forgatás tengely körül: a láthatóság számításához konvex zárólap-szektorok kellenek +@msg Revolve visibility requires finite projected faces Forgatás tengely körül: a láthatóság számításához véges vetített lapok kellenek +@msg SVG options: {e} SVG-beállítások: {e} +@msg Save for Web settings: {e} Webes mentés beállításai: {e} +@msg TIFF colour profile: {e} TIFF-színprofil: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size A TIFF-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg TIFF encoding failed: {e} A TIFF-kódolás sikertelen: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-színmélység {_1}: 16, 24 vagy 32 bit/pixel lehet +@msg Targa encoding failed: the pixel buffer doesn't match the image size A Targa-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg The 3D projection is not finite A 3D vetület nem véges +@msg The 3D shadow is too complex; turn Shadows off or simplify the art A 3D árnyék túl összetett; kapcsold ki az Árnyékok beállítást, vagy egyszerűsítsd a rajzot +@msg The shape contains invalid coordinates Az alakzat érvénytelen koordinátákat tartalmaz +@msg Too many Revolve faces for visibility evaluation Túl sok lap a láthatóság kiértékeléséhez (Forgatás tengely körül) +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} A VectorCraft legfeljebb {ASE_MAX_ENTRIES} színt és csoportot olvas be egy színmintacsere-fájlból, ebben a könyvtárban pedig ennyi van: {entries} +@msg WebP encoding failed: {e} A WebP-kódolás sikertelen: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `index` must be a whole number `index`: egész szám kell +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view`: smallThumb, mediumThumb, largeThumb, smallList vagy largeList lehet +@msg a line can't run from a point to itself egy vonal nem futhat egy pontból önmagába +@msg a line needs at least two points egy vonalhoz legalább két pont kell +@msg a number cannot be read egy szám nem olvasható be +@msg a swatch exchange block is too long egy színmintacsere-blokk túl hosszú +@msg a swatch exchange name is too long egy színmintacsere-név túl hosszú +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels egy {sw} × {sh} méretű kép nem méretezhető át erre: {width} × {height} pixel +@msg a {width} × {height} image is too large egy {width} × {height} méretű kép túl nagy +@msg a {width} × {height} image: {e} egy {width} × {height} méretű kép: {e} +@msg an image could not be written: {e} egy képet nem sikerült kiírni: {e} +@msg an image that couldn't be read was left out kimaradt egy kép, amely nem volt beolvasható +@msg can't open “{name}”: {e} nem nyitható meg: „{name}”: {e} +@msg cannot align zip entries to {align} bytes a zip-bejegyzések nem igazíthatók {align} bájtos határra +@msg cannot open the automation {kind} root `{_1}`: {e} az automatizálás gyökérmappája nem nyitható meg ({kind}): `{_1}`: {e} +@msg cannot read profile: {0} a profil nem olvasható: {0} +@msg color book version {version} isn't supported (only 1) a színkönyv verziója nem támogatott: {version} (csak 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) a színkönyvek nem támogatottak ebben a színtérben: {other} (csak RGB, CMYK és Lab) +@msg file name too long to zip: {_1} a fájlnév túl hosszú a zip-tömörítéshez: {_1} +@msg file name too long to zip: {name} a fájlnév túl hosszú a zip-tömörítéshez: {name} +@msg freeform line {i}: {e} szabad formájú vonal {i}: {e} +@msg freeform point {i}: {e} szabad formájú pont {i}: {e} +@msg give `{key}` or `{other}`, not both `{key}` vagy `{other}`: csak az egyiket add meg, ne mindkettőt +@msg grayscale profiles can't be working spaces yet a szürkeárnyalatos profilok egyelőre nem lehetnek munkaszínterek +@msg input contains NaN or infinite coordinates a bemenet NaN vagy végtelen koordinátákat tartalmaz +@msg input path could not be closed a bemeneti útvonalat nem sikerült lezárni +@msg invalid ICC profile: {0} érvénytelen ICC-profil: {0} +@msg invalid scale {scale} érvénytelen méretarány: {scale} +@msg its localized names take more than {_1} KB a honosított nevei több mint {_1} KB helyet foglalnak +@msg its objects are nested too deep ({e}) az objektumai túl mélyen egymásba ágyazottak ({e}) +@msg its tag table lists the `{_1}` tag more than once a címketáblája többször is felsorolja ezt a címkét: `{_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 a túl sok egymást átfedő áttetsző elemet tartalmazó kiütő csoportok PDF-kiütőcsoportként íródnak: az áttetsző elemeik teljesen kiütik azt, amit takarnak +@msg not a graphic style library: {e} nem grafikaistílus-könyvtár: {e} +@msg not a swatch library (.vcswatches or .gpl) nem színminta-könyvtár (.vcswatches vagy .gpl) +@msg not a swatch library (format `{_1}`) nem színminta-könyvtár (formátum: `{_1}`) +@msg not a swatch library: {e} nem színminta-könyvtár: {e} +@msg not an action set file ({e}) nem műveletkészlet-fájl ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray a profil színtere nem RGB, CMYK vagy szürkeárnyalatos: {other} +@msg regular expression too large a reguláris kifejezés túl nagy +@msg script file is not UTF-8 text: {value} a parancsfájl nem UTF-8 szöveg: {value} +@msg stop {i}: {e} színátmenet-pont {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) a színmintacsere-fájl verziója nem támogatott: {major}.{minor} (csak 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors a színkönyv több mint {ASE_MAX_ENTRIES} színt tartalmaz +@msg the color book is cut short a színkönyv csonka +@msg the color book's names take more than {_1} MB a színkönyv nevei több mint {_1} MB helyet foglalnak +@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 kép mérete {w} × {h} pixel lenne; a raszteres exportálás oldalanként legfeljebb {MAX_RASTER_SIDE} pixelt és összesen legfeljebb {_1} megapixelt enged: csökkentsd a méretarányt vagy a felbontást +@msg the optimised image can't be read back: {e} az optimalizált kép nem olvasható vissza: {e} +@msg the pixel buffer doesn't match the image size a pixelpuffer nem egyezik a kép méretével +@msg the preview: {e} az előnézeti kép: {e} +@msg the request may not read the New Document profiles folder a kérés nem olvashatja az Új dokumentum profiljainak mappáját +@msg the shapes are too degenerate to combine az alakzatok túlságosan elfajultak az egyesítéshez +@msg the shapes cross themselves and each other too often to combine az alakzatok túl sokszor keresztezik önmagukat és egymást az egyesítéshez +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups a színmintacsere-fájl több mint {ASE_MAX_ENTRIES} színt és csoportot tartalmaz +@msg the swatch exchange file is cut short a színmintacsere-fájl csonka +@msg the {_1} plate can't be read: {e} a nyomólemez nem olvasható be ({_1}): {e} +@msg this binary DXF file is damaged or cut short ez a bináris DXF-fájl sérült vagy csonka +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that ez DWG-rajz, amely nem olvasható: mentsd DXF-ként a CAD-alkalmazásból, és azt nyisd meg +@msg too many classes túl sok karakterosztály +@msg too many groups túl sok csoport +@msg too many layers for PSD (at most 32767) túl sok réteg a PSD formátumhoz (legfeljebb 32767) +@msg too many swatch exchange blocks túl sok színmintacsere-blokk +@msg unknown colour profile: {0} ismeretlen színprofil: {0} +@msg {_1} clashes with a built-in profile name {_1}: ütközik egy beépített profil nevével +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixel túl nagy a GIF formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást +@msg {_1}: no usable transform to/from sRGB {_1}: nincs használható átalakítás az sRGB színtérbe vagy onnan +@msg {name} is not a CMYK profile {name}: nem CMYK-profil +@msg {name} is not an RGB profile {name}: nem RGB-profil +@msg {path}: could not be read {path}: nem sikerült beolvasni +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixel túl nagy a JPEG formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixel túl nagy a Targa formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixel túl nagy a PSD formátumhoz (oldalanként legfeljebb {MAX_SIDE} pixel): csökkentsd a felbontást diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f8069f6a71..c7c6b956db 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1509,6 +1509,7 @@ lighting Light Cahaya Proportional Metrics Metrik proporsional Insert Aki Left Sisipkan aki kiri Insert Aki Right Sisipkan aki kanan + Tsume Tsume Character Direction Arah karakter Digits Digit Arabic Arab @@ -6879,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 af875b4e42..6743704785 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2708,6 +2708,7 @@ mark-style Japanese Giapponese Proportional Metrics Metrica proporzionale Insert Aki Left Inserisci aki a sinistra Insert Aki Right Inserisci aki a destra + Tsume Tsume Character Direction Direzione caratteri Digits Cifre Arabic Arabe @@ -4796,6 +4797,7 @@ axis Both Entrambi @msg no action set to save nessun set di azioni da salvare @msg the action set file can't be read in full il file di set di azioni non può essere letto per intero @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi +@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}` Caricato “{name}”: {n} passaggio ha un comando sconosciuto ed è stato mantenuto: “{action}”, passaggio {step}, `{command}`|Caricato “{name}”: {n} passaggi hanno comandi sconosciuti e sono stati mantenuti; il primo: “{action}”, passaggio {step}, `{command}` @msg not an action set file non è un file di set di azioni @msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: il riferimento supererebbe {MAX_BYTES} byte @@ -5037,6 +5039,7 @@ axis Both Entrambi @msg tool shortcuts must be a single key (optionally with Shift) le scelte rapide degli strumenti devono essere un singolo tasto (eventualmente con Shift) @msg truncated troncato @msg unknown command `{0}` comando sconosciuto `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` il passaggio {step} di “{action}” esegue il comando sconosciuto `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando sconosciuto `{0}`; forse intendevate `{1}`? @msg unknown effect `{id}` effetto sconosciuto `{id}` @msg unknown format formato sconosciuto @@ -6019,8 +6022,8 @@ axis Y Axis Asse Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di esportazione dichiara da 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` al massimo {MAX_UNGROUPED} parametri fuori dai gruppi: assegna agli altri un `group` @msg can't encode the input impossibile codificare l’input -@msg expected {"dataBase64"} previsto {"dataBase64"} -@msg expected {"objects"} previsto {"objects"} +@msg expected {{"dataBase64": "…"}} previsto {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} previsto {{"objects": [...]}} @msg export `{name}`: {e} esportazione del modulo `{name}`: {e} @msg extension {e} is listed twice l’estensione {e} è elencata due volte @msg extension {e}: use 1-16 lower-case letters and digits, without the dot estensione {e}: usa 1-16 lettere minuscole e cifre, senza il punto @@ -6084,11 +6087,11 @@ axis Y Axis Asse Y @msg a painted live effect returns only `objects` un effetto dinamico di pittura restituisce solo `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` un pezzo di un effetto dinamico di pittura ha solo `path`, `fillRule`, `fills` e `strokes` @msg a read-only object was left out è stato omesso un oggetto di sola lettura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {"id"} da solo o così come è stato fornito +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {{"id": …}} da solo o così come è stato fornito @msg a string una stringa @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` una traccia ha solo `paint`, `width`, `join`, `cap` e `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} una traccia deve essere {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un effetto deve essere {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} una traccia deve essere {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un effetto deve essere {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un effetto su un riempimento restituisce solo `fills` @msg an effect on a stroke returns only `strokes` un effetto su una traccia restituisce solo `strokes` @msg an object must be a JSON object un oggetto deve essere un oggetto JSON @@ -7099,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 @@ -8926,6 +8935,134 @@ breakText Glyphs Glifi @msg {what} can't be intertwined {what} non può essere intrecciato @msg {what} doesn't fit the paper with its bleed and marks {what} non entra nella carta con l'abbondanza e i segni @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} verrebbe stampato su più di {MAX_TILES} riquadri: riducilo +# Messages no catalog translated yet: image export and color profiles, color books and swatch exchange files, 3D and Revolve, and script and file errors. +@msg 3D effects require finite coordinates within 100 million points Gli effetti 3D richiedono coordinate finite entro 100 milioni di punti +@msg 3D effects require one to ten lights Gli effetti 3D richiedono da una a dieci luci +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Profondità BMP {_1}: 1, 4, 8, 16, 24 o 32 bit per pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Codifica BMP non riuscita: {width} × {height} pixel non sono compatibili con il formato o con il buffer dei pixel +@msg GIF encoding failed: the palette image is malformed Codifica GIF non riuscita: l'immagine con palette non è valida +@msg GIF encoding failed: {e} Codifica GIF non riuscita: {e} +@msg JPEG colour profile: {e} Profilo colore JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Codifica JPEG non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg JPEG encoding failed: {e} Codifica JPEG non riuscita: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Codifica PNG non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg PNG encoding failed: the palette image is malformed Codifica PNG non riuscita: l'immagine con palette non è valida +@msg PNG encoding failed: {e} Codifica PNG non riuscita: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Codifica PNG non riuscita: {width} × {height} pixel non è una dimensione valida per un'immagine +@msg PSD colour profile: {e} Profilo colore PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Codifica PSD non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg Revolve profile contains invalid coordinates Rivoluzione: il profilo contiene coordinate non valide +@msg Revolve profile is too complex Rivoluzione: il profilo è troppo complesso +@msg Revolve projection is not finite Rivoluzione: la proiezione non è finita +@msg Revolve requires finite profile coordinates within 100 million points Rivoluzione richiede coordinate del profilo finite entro 100 milioni di punti +@msg Revolve surface is too complex; reduce Segments or simplify the profile Rivoluzione: la superficie è troppo complessa; riduci Segmenti o semplifica il profilo +@msg Revolve surface is too complex; simplify the profile Rivoluzione: la superficie è troppo complessa; semplifica il profilo +@msg Revolve visibility cannot split the projected boundary Rivoluzione: il calcolo della visibilità non può dividere il contorno proiettato +@msg Revolve visibility creates too many fragments Rivoluzione: il calcolo della visibilità crea troppi frammenti +@msg Revolve visibility creates too many vector anchors Rivoluzione: il calcolo della visibilità crea troppi punti di ancoraggio vettoriali +@msg Revolve visibility exceeds its geometry work limit Rivoluzione: il calcolo della visibilità supera il suo limite di lavoro geometrico +@msg Revolve visibility exceeds its spatial index limit Rivoluzione: il calcolo della visibilità supera il suo limite di indice spaziale +@msg Revolve visibility has too many boundary crossings Rivoluzione: il calcolo della visibilità ha troppi attraversamenti del contorno +@msg Revolve visibility has too many input anchors Rivoluzione: il calcolo della visibilità ha troppi punti di ancoraggio in ingresso +@msg Revolve visibility requires convex cap sectors Rivoluzione: il calcolo della visibilità richiede settori convessi delle basi +@msg Revolve visibility requires finite projected faces Rivoluzione: il calcolo della visibilità richiede facce proiettate finite +@msg SVG options: {e} Opzioni SVG: {e} +@msg Save for Web settings: {e} Impostazioni di Salva per il web: {e} +@msg TIFF colour profile: {e} Profilo colore TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Codifica TIFF non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg TIFF encoding failed: {e} Codifica TIFF non riuscita: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Profondità Targa {_1}: 16, 24 o 32 bit per pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Codifica Targa non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg The 3D projection is not finite La proiezione 3D non è finita +@msg The 3D shadow is too complex; turn Shadows off or simplify the art L'ombra 3D è troppo complessa; disattiva Ombre o semplifica la grafica +@msg The shape contains invalid coordinates La forma contiene coordinate non valide +@msg Too many Revolve faces for visibility evaluation Troppe facce di Rivoluzione per il calcolo della visibilità +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft legge al massimo {ASE_MAX_ENTRIES} colori e gruppi da un file di scambio campioni, e questa libreria ne ha {entries} +@msg WebP encoding failed: {e} Codifica WebP non riuscita: {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 o largeList +@msg a line can't run from a point to itself una linea non può andare da un punto a sé stesso +@msg a line needs at least two points una linea richiede almeno due punti +@msg a number cannot be read impossibile leggere un numero +@msg a swatch exchange block is too long un blocco del file di scambio campioni è troppo lungo +@msg a swatch exchange name is too long un nome nel file di scambio campioni è troppo lungo +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels un'immagine di {sw} × {sh} non può essere ridimensionata a {width} × {height} pixel +@msg a {width} × {height} image is too large un'immagine di {width} × {height} è troppo grande +@msg a {width} × {height} image: {e} un'immagine di {width} × {height}: {e} +@msg an image could not be written: {e} impossibile scrivere un'immagine: {e} +@msg an image that couldn't be read was left out un'immagine che non è stato possibile leggere è stata omessa +@msg can't open “{name}”: {e} impossibile aprire “{name}”: {e} +@msg cannot align zip entries to {align} bytes impossibile allineare le voci zip a {align} byte +@msg cannot open the automation {kind} root `{_1}`: {e} impossibile aprire la cartella radice {kind} dell'automazione `{_1}`: {e} +@msg cannot read profile: {0} impossibile leggere il profilo: {0} +@msg color book version {version} isn't supported (only 1) la versione {version} del libro colori non è supportata (solo 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) i libri colori nello spazio colore {other} non sono supportati (solo RGB, CMYK e Lab) +@msg file name too long to zip: {_1} nome file troppo lungo per un archivio zip: {_1} +@msg file name too long to zip: {name} nome file troppo lungo per un archivio zip: {name} +@msg freeform line {i}: {e} linea libera {i}: {e} +@msg freeform point {i}: {e} punto libero {i}: {e} +@msg give `{key}` or `{other}`, not both indica `{key}` o `{other}`, non entrambi +@msg grayscale profiles can't be working spaces yet i profili in scala di grigi non possono ancora essere spazi di lavoro +@msg input contains NaN or infinite coordinates l'input contiene coordinate NaN o infinite +@msg input path could not be closed impossibile chiudere il tracciato di input +@msg invalid ICC profile: {0} profilo ICC non valido: {0} +@msg invalid scale {scale} scala non valida {scale} +@msg its localized names take more than {_1} KB i suoi nomi localizzati occupano più di {_1} KB +@msg its objects are nested too deep ({e}) i suoi oggetti sono annidati troppo in profondità ({e}) +@msg its tag table lists the `{_1}` tag more than once la sua tabella dei tag elenca il tag `{_1}` più di una volta +@msg not a graphic style library: {e} non è una libreria di stili grafici: {e} +@msg not a swatch library (.vcswatches or .gpl) non è una libreria campioni (.vcswatches o .gpl) +@msg not a swatch library (format `{_1}`) non è una libreria campioni (formato `{_1}`) +@msg not a swatch library: {e} non è una libreria campioni: {e} +@msg not an action set file ({e}) non è un file di set di azioni ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray lo spazio colore {other} del profilo non è RGB, CMYK o scala di grigi +@msg regular expression too large espressione regolare troppo grande +@msg script file is not UTF-8 text: {value} il file di script non è testo UTF-8: {value} +@msg stop {i}: {e} cursore colore {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) la versione {major}.{minor} del file di scambio campioni non è supportata (solo 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors il libro colori contiene più di {ASE_MAX_ENTRIES} colori +@msg the color book is cut short il libro colori è troncato +@msg the color book's names take more than {_1} MB i nomi del libro colori occupano più di {_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 l'immagine misurerebbe {w} × {h} pixel; le esportazioni raster sono limitate a {MAX_RASTER_SIDE} pixel per lato e {_1} megapixel: riduci la scala o la risoluzione +@msg the optimised image can't be read back: {e} impossibile rileggere l'immagine ottimizzata: {e} +@msg the pixel buffer doesn't match the image size il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg the preview: {e} l'anteprima: {e} +@msg the request may not read the New Document profiles folder la richiesta non può leggere la cartella dei profili di nuovo documento +@msg the shapes are too degenerate to combine le forme sono troppo degeneri per essere combinate +@msg the shapes cross themselves and each other too often to combine le forme si intersecano con sé stesse e tra loro troppe volte per essere combinate +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups il file di scambio campioni contiene più di {ASE_MAX_ENTRIES} colori e gruppi +@msg the swatch exchange file is cut short il file di scambio campioni è troncato +@msg the {_1} plate can't be read: {e} impossibile leggere la lastra {_1}: {e} +@msg this binary DXF file is damaged or cut short questo file DXF binario è danneggiato o troncato +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that questo è un disegno DWG, che non può essere letto: salvalo dall'app CAD come DXF e apri quel file +@msg too many classes troppe classi di caratteri +@msg too many groups troppi gruppi +@msg too many layers for PSD (at most 32767) troppi livelli per PSD (al massimo 32767) +@msg too many swatch exchange blocks troppi blocchi nel file di scambio campioni +@msg unknown colour profile: {0} profilo colore sconosciuto: {0} +@msg {_1} clashes with a built-in profile name {_1} coincide con il nome di un profilo incorporato +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixel è troppo grande per GIF (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {_1}: no usable transform to/from sRGB {_1}: nessuna trasformazione utilizzabile da/verso sRGB +@msg {name} is not a CMYK profile {name} non è un profilo CMYK +@msg {name} is not an RGB profile {name} non è un profilo RGB +@msg {path}: could not be read {path}: lettura non riuscita +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixel è troppo grande per JPEG (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixel è troppo grande per Targa (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixel è troppo grande per PSD (al massimo {MAX_SIDE} pixel per lato): riduci la risoluzione +# Messages other catalogs translate and Italian did not: Banner Hems and Grommets, parameter checks and recovery data. +@msg `{key}` must be from 0.01 to 1000 pt `{key}` deve essere compreso tra 0,01 e 1000 pt +@msg select the banner, or mark every artboard seleziona lo striscione, oppure contrassegna ogni tavola da disegno +@msg the banner has no width or height lo striscione non ha larghezza o altezza +@msg the grommet inset is too large for the banner la distanza degli occhielli dal bordo è troppo grande per lo striscione +@msg the hem is too deep for the banner l'orlo è troppo profondo per lo striscione +@msg the spacing makes more than {MAX_GROMMETS} grommets la spaziatura produce più di {MAX_GROMMETS} occhielli +@msg there is no banner to mark non c'è nessuno striscione da contrassegnare +@msg turn on at least one side attiva almeno un lato +@msg unknown parameter `{within}{key}`; {why} parametro sconosciuto `{within}{key}`; {why} +@msg with no hem and no grommets there is nothing to mark senza orlo e senza occhielli non c'è nulla da contrassegnare +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} deve essere {lo}–{hi}{unit} (ricevuto {v}) +@msg {saving} failed: {e} {saving} non riuscito: {e} Banner Hems and Grommets… Orli e occhielli per striscioni… Banner Hems and Grommets Orli e occhielli per striscioni Hem Orlo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e6d40c9ae5..c9cdb8fcbe 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -52,6 +52,7 @@ font-technology Variable バリアブル Proportional Metrics プロポーショナルメトリクス Insert Aki Left 前にアキを挿入 Insert Aki Right 後ろにアキを挿入 + Tsume 文字ツメ Character Direction 文字の方向 Digits 数字 Arabic アラビア数字 @@ -4714,6 +4715,7 @@ swatch Bright Violet ビビッドバイオレット @msg objects can't be nested more than 32 deep オブジェクトは 32 階層より深く入れ子にできません @msg the SVG's elements are nested more than {0} deep SVG の要素が {0} 階層より深く入れ子になっています @msg unknown command `{0}` `{0}` は不明なコマンドです +@msg step {step} of “{action}” runs the unknown command `{command}` 「{action}」のステップ {step} は不明なコマンド `{command}` を実行します @msg unknown command `{0}`; did you mean `{1}`? `{0}` は不明なコマンドです。`{1}` のことですか? @msg `{what}` closes nothing `{what}` に対応する開始部分がありません @msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています @@ -5063,6 +5065,7 @@ swatch Bright Violet ビビッドバイオレット @msg no action set to save 保存するアクションセットがありません @msg the action set file can't be read in full アクションセットファイルをすべて読み込めません @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました +@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}」を読み込みました。不明なコマンドのステップ {n} 個はそのまま残しました。最初は「{action}」のステップ {step}、`{command}` です @msg not an action set file アクションセットファイルではありません @msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: リファレンスが {MAX_BYTES} バイトを超えます @@ -6085,8 +6088,8 @@ axis Y Axis Y 軸 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 書き出しプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` グループ外のパラメーターは {MAX_UNGROUPED} 個までです。ほかのパラメーターには `group` を指定してください @msg can't encode the input 入力をエンコードできません -@msg expected {"dataBase64"} {"dataBase64"} が必要です -@msg expected {"objects"} {"objects"} が必要です +@msg expected {{"dataBase64": "…"}} {{"dataBase64": "…"}} が必要です +@msg expected {{"objects": [...]}} {{"objects": [...]}} が必要です @msg export `{name}`: {e} モジュールのエクスポート `{name}`: {e} @msg extension {e} is listed twice 拡張子 {e} が 2 回指定されています @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 拡張子 {e}: ドットを付けずに小文字と数字で 1-16 文字にしてください @@ -6150,11 +6153,11 @@ axis Y Axis Y 軸 @msg a painted live effect returns only `objects` ペイント型のライブ効果が返すのは `objects` だけです @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ペイント型のライブ効果のピースに使えるのは `path`、`fillRule`、`fills`、`strokes` だけです @msg a read-only object was left out 読み取り専用のオブジェクトが省かれています -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 読み取り専用のオブジェクトが省かれています: {"id"} だけの形か、渡されたとおりの形で返してください +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 読み取り専用のオブジェクトが省かれています: {{"id": …}} だけの形か、渡されたとおりの形で返してください @msg a string 文字列 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 線に使えるのは `paint`、`width`、`join`、`cap`、`miterLimit` だけです -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 線は {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} の形にしてください -@msg an effect must be {"id", "params"?, "visible"?} 効果は {"id", "params"?, "visible"?} の形にしてください +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 線は {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} の形にしてください +@msg an effect must be {{"id", "params"?, "visible"?}} 効果は {{"id", "params"?, "visible"?}} の形にしてください @msg an effect on a fill returns only `fills` 塗りの効果が返すのは `fills` だけです @msg an effect on a stroke returns only `strokes` 線の効果が返すのは `strokes` だけです @msg an object must be a JSON object オブジェクトは JSON オブジェクトにしてください @@ -6340,6 +6343,1810 @@ axis Y Axis Y 軸 @msg no menu item that can be inserted runs this command このコマンドを実行する、挿入できるメニュー項目はありません @msg the action already has 10,000 steps アクションにはすでに 10,000 ステップがあります @msg the action set would be larger than 8 MiB アクションセットが 8 MiB を超えます +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions strict モードの関数では 'caller'、'callee'、'arguments' を使用できません +@msg Alignment must be one of the choices 揃えには選択肢のいずれかを指定してください +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} が Boolean ではない値に対して呼び出されました +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB サンプルを CMYK で読み取れなかった CMYK 画像は RGB で読み込みました +@msg Cannot write to the location of “{name}”; choose where to save it 「{name}」の場所に書き込めません。保存先を選択してください +@msg DCS files can only be placed linked: use File › Place with Link on DCS ファイルはリンクとしてのみ配置できます: 「リンク」をオンにして「ファイル › 配置」を使用してください +@msg Date.prototype method called on a value that is not a Date object Date.prototype のメソッドが Date オブジェクトではない値に対して呼び出されました +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString がオブジェクトではない値に対して呼び出されました +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString が関数ではない値に対して呼び出されました +@msg Import SVG Filter reads SVG files, not SVGZ 「SVG フィルターを読み込み」が読み込むのは SVG ファイルで、SVGZ ファイルは読み込みません +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (オフセット {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify は自身を含む構造を書き出せません +@msg Line Gap must be a number 行間には数値を指定してください +@msg Namespace.prototype.toString needs a Namespace Namespace.prototype.toString には Namespace が必要です +@msg New Document profiles need the preferences folder, which this run doesn't have 新規ドキュメントプロファイルには環境設定フォルダーが必要ですが、この実行環境にはありません +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} が数値ではない値に対して呼び出されました +@msg Object.create's prototype must be an object or null Object.create のプロトタイプにはオブジェクトまたは null を指定してください +@msg Object.{function} was given a value that is not an object Object.{function} にオブジェクトではない値が渡されました +@msg QName.prototype.toString needs a QName QName.prototype.toString には QName が必要です +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} が RegExp オブジェクトではない値に対して呼び出されました +@msg Stencil Bridges: the selected art is too large or too detailed to bridge ステンシルブリッジ: 選択したアートが大きすぎるか細かすぎるため、ブリッジを作成できません +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString が文字列ではない値に対して呼び出されました +@msg The {name} profile could not be read, so the new document has the default settings: {reason} {name} プロファイルを読み込めなかったため、新規ドキュメントには初期設定を使用しました: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time トラップ: トラップできるほど近いオブジェクトの組が {_1} 組を超えているか、水平方向の範囲が重なるオブジェクトが多すぎます。一度にトラップするオブジェクトを減らしてください +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off カス取り枠とライン: アートの外側に収まるカス取りラインがなく、カス取り枠もオフになっています +@msg \\ at end of pattern パターンの末尾に \\ があります +@msg `alignOn` cannot be empty `alignOn` を空にすることはできません +@msg `alignOn` must be a string `alignOn` には文字列を指定してください +@msg `align` must be a string `align` には文字列を指定してください +@msg `answer` for this {kind} dialog must be one of: {_1} この {kind} ダイアログの `answer` には次のいずれかを指定してください: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers` の回答が {MAX_ANSWERS} 個を超えています +@msg `answers` is larger than 1 MiB `answers` が 1 MiB を超えています +@msg `answers` must be a list of answers `answers` には回答のリストを指定してください +@msg `artboard` must be the index of an artboard of the document `artboard` にはドキュメントのアートボードのインデックスを指定してください +@msg `artboardsAt` must be [x, y] `artboardsAt` には [x, y] を指定してください +@msg `artboards` names an artboard the document does not have `artboards` にドキュメントにないアートボードが指定されています +@msg `artboards` names an artboard twice `artboards` に同じアートボードが 2 回指定されています +@msg `at` must be [x, y] (got {at}) `at` には [x, y] を指定してください (指定値: {at}) +@msg `at` must be a point [x, y] `at` には点 [x, y] を指定してください +@msg `beside` needs `side` `beside` には `side` が必要です +@msg `code` must be a whole number `code` には整数を指定してください +@msg `colorMode` must be one of rgb, cmyk `colorMode` には rgb、cmyk のいずれかを指定してください +@msg `colors` does not apply to the {name} reduction `colors` は {name} 減色には適用されません +@msg `colors` must be a whole number from 2 to 256 `colors` には 2 から 256 までの整数を指定してください +@msg `colors` must be at least 9 for the uniform reduction 均等減色では `colors` に 9 以上を指定してください +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` には 1 から {MAX_THEME_COLORS} までの値を指定してください (指定値: {n}) +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` には 0 から {MAX_COUNT} までの整数を指定してください +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` には 0 から 100 までの数値を指定してください +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` には {lo}~{hi} pt の値を指定してください +@msg `focal` and `radius` cannot be used together `focal` と `radius` は同時に使用できません +@msg `focal` must be an array of [subpath, anchor] pairs `focal` には [subpath, anchor] の組の配列を指定してください +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` に指定された [{si}, {ai}] はパスのアンカーではありません +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` に指定できるのは null のみで、字形の選択を解除します (選択は text.setGlyphAlternate で行います) +@msg `group` must be one of {_1} `group` には {_1} のいずれかを指定してください +@msg `group` must be the Objects on Path group of `member` and `target` `group` には `member` と `target` のパス上のオブジェクトグループを指定してください +@msg `id` is an object id, not {v} `id` にはオブジェクト ID を指定してください ({v} は指定できません) +@msg `item` needs `index` `item` には `index` が必要です +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` は文字スタイルではなく行の先頭文字の設定です (text.setRangeStyle で設定します) +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` には "pushInFirst"、"pushOutFirst"、"pushOutOnly" のいずれかを指定してください +@msg `leader` must be a string `leader` には文字列を指定してください +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` には 1~{MAX_SUGGESTIONS} を指定してください +@msg `name` must be a name that is not empty `name` には空でない名前を指定してください +@msg `name` must not be empty `name` を空にすることはできません +@msg `origin` must be [x, y] in points `origin` にはポイント単位の [x, y] を指定してください +@msg `picks` must be an array (got {v}) `picks` には配列を指定してください (指定値: {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` には 1~{MAX_PICKS} 個の指定が必要です (指定数: {_1}) +@msg `points` must hold from 2 to 100000 points `points` には 2~100000 個の点が必要です +@msg `radius` must be 0 or more `radius` には 0 以上を指定してください +@msg `rect` must be [x, y, width, height] `rect` には [x, y, width, height] を指定してください +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` には [x0, y0, x1, y1] を指定してください (指定値: {r}) +@msg `replace` must be a string `replace` には文字列を指定してください +@msg `rows` must be lists of numbers or nulls, not {r} `rows` には数値または null のリストを指定してください ({r} は指定できません) +@msg `scale` must be a number from 0.001 to 1000 `scale` には 0.001 から 1000 までの数値を指定してください +@msg `scope.{key}` must be a non-empty array `scope.{key}` には空でない配列を指定してください +@msg `scope` must be "document", {artboards} or {layers} `scope` には "document"、{artboards}、{layers} のいずれかを指定してください +@msg `side` must be "left", "right", "top" or "bottom" `side` には "left"、"right"、"top"、"bottom" のいずれかを指定してください +@msg `side` places the artboard: leave out x, y, width and height `side` でアートボードを配置します: x、y、width、height は省略してください +@msg `source` is the only path in series group `target` `source` は系列グループ `target` の唯一のパスです +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` ではボックス全体のラインが {MAX_COUNT} 本を超えます +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` には 0 より大きく {MAX_DISTANCE} pt 以下の値を指定してください +@msg `target` must be an object of the same Objects on Path group as `member` `target` には `member` と同じパス上のオブジェクトグループのオブジェクトを指定してください +@msg `tolerance` must be from 0 to 100 pt `tolerance` には 0~100 pt の値を指定してください +@msg `version` must be 0 or {_1} `version` には 0 または {_1} を指定してください +@msg `version` must be 0 or {newest} `version` には 0 または {newest} を指定してください +@msg `warichuAlign` must be one of {_1} `warichuAlign` には {_1} のいずれかを指定してください +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` には {_1} から {_2} までの整数を指定してください +@msg `weight` must be from 0.01 to 1000 pt `weight` には 0.01~1000 pt の値を指定してください +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` には行ごとに 1 つずつ、{_1} 個の数値のリストを指定してください (指定値: {v}) +@msg `xml` must be a string `xml` には文字列を指定してください +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}` のエントリ {n} は、{_1} のバイト {start} の段落に、前のエントリとは異なるインデントを指定しています +@msg `{KEY}` entry {n} must be an object `{KEY}` のエントリ {n} にはオブジェクトを指定してください +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}` のエントリ {n} は `leftIndent` も `firstLineIndent` も設定していません +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}` のエントリ {n}: `id` にはオブジェクト ID を指定してください +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}` のエントリ {n}: `start` にはバイトオフセットを指定してください +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}` のエントリ {n}: `{k}` には -{MAX_PARA}~{MAX_PARA} pt の数値を指定してください +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}` のエントリ {n}: {_1} にはバイト {start} から始まる段落がありません +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}` のエントリ {n}: `{k}` は不明なキーです +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}` のエントリ {n}: {_1} はテキストオブジェクトではありません +@msg `{KEY}` is empty `{KEY}` が空です +@msg `{KEY}` must be a list `{KEY}` にはリストを指定してください +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` に指定できるエントリは最大 {MAX_PARAGRAPH_INDENTS} 個です (指定数: {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}` と一緒に `{other}` は指定できません +@msg `{base}` is larger than {_1} MB `{base}` は {_1} MB を超えています +@msg `{base}` is not in the DCS file's folder `{base}` は DCS ファイルのフォルダーにありません +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` の単語が {MAX_EDIT_WORDS} 個を超えています +@msg `{key}` must be 0 or more `{key}` には 0 以上を指定してください +@msg `{key}` must be [x, y] numbers `{key}` には数値の [x, y] を指定してください +@msg `{key}` must be a list of words `{key}` には単語のリストを指定してください +@msg `{key}` must be a list, not {v} `{key}` にはリストを指定してください ({v} は指定できません) +@msg `{key}` must be a number or null, not {v} `{key}` には数値または null を指定してください ({v} は指定できません) +@msg `{key}` must be a whole number of 0 or more `{key}` には 0 以上の整数を指定してください +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` には 0~{MAX_DISTANCE} pt の値を指定してください +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` には {lo}~{hi} の値を指定してください ({v} は指定できません) +@msg `{key}` must be one of auto, always, never `{key}` には auto、always、never のいずれかを指定してください +@msg `{key}` must be one of miter, round, bevel `{key}` には miter、round、bevel のいずれかを指定してください +@msg `{key}` must be one word of at most {_1} characters `{key}` には {_1} 文字以内の 1 語を指定してください +@msg `{key}` must hold single words of at most {_1} characters `{key}` には {_1} 文字以内の単語を 1 語ずつ指定してください +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` にはパス上のオブジェクトグループのパスに配置されたオブジェクトを指定してください +@msg `{key}` names an object that is hidden or locked `{key}` に指定されたオブジェクトは非表示またはロックされています +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` は独立したファイルで、DCS ファイルのパスと同じ場所でのみ見つけられます +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{u}` is no Unicode character `{u}` は Unicode 文字ではありません +@msg `{word}` isn't in the custom dictionary `{word}` はカスタム辞書にありません +@msg a CMap has too many mappings CMap のマッピングが多すぎます +@msg a \\u escape needs four hexadecimal digits \\u エスケープには 4 桁の 16 進数が必要です +@msg a comma in an XPath expression must separate function arguments XPath 式のカンマは関数の引数を区切るものでなければなりません +@msg a document holds at most {_1} SVG filters ドキュメントに含められる SVG フィルターは最大 {_1} 個です +@msg a document with unsaved changes has no recovery copy 未保存の変更があるドキュメントに復帰用のコピーがありません +@msg a font's data is too large フォントのデータが大きすぎます +@msg a font's subroutines are too large フォントのサブルーチンが大きすぎます +@msg a function declaration without a name 名前のない関数宣言です +@msg a host value has too many entries ホストの値のエントリが多すぎます +@msg a host value is nested too deep ホストの値の入れ子が深すぎます +@msg a namespace declaration uses a reserved prefix or URI 名前空間の宣言で予約済みのプレフィックスまたは URI が使用されています +@msg a namespace qualifier is undefined 名前空間の修飾子が定義されていません +@msg a namespace with an empty URI cannot have a prefix URI が空の名前空間にはプレフィックスを付けられません +@msg a number has no digits 数値に数字がありません +@msg a number's exponent has no digits 数値の指数部に数字がありません +@msg a number's fraction has no digits 数値の小数部に数字がありません +@msg a property descriptor cannot have both a value or writable and a get or set プロパティ記述子に value または writable と get または set の両方を指定することはできません +@msg a property descriptor must be an object プロパティ記述子にはオブジェクトを指定してください +@msg a property's {name} must be a function or undefined プロパティの {name} には関数または undefined を指定してください +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied 含まれるストリームをデコードすると 256 MB を超えるか、デコードパラメーターを適用できません +@msg a string holds a control character 文字列に制御文字が含まれています +@msg a string holds an escape sequence JSON does not have 文字列に JSON にないエスケープシーケンスが含まれています +@msg a string is not closed 文字列が閉じていません +@msg an SVG filter is named by its id: give `xml` with `name` SVG フィルターの名前はその ID です: `xml` と `name` を指定してください +@msg an XML element has two attributes with the same name XML 要素に同じ名前の属性が 2 つあります +@msg an XML value cannot be inserted into itself or its descendants XML 値をそれ自身またはその子孫に挿入することはできません +@msg an XML value's children cannot be assigned by position XML 値の子を位置で代入することはできません +@msg an XML value's children cannot be deleted by position XML 値の子を位置で削除することはできません +@msg an XMLList converts to XML only when it has one item XMLList を XML に変換できるのは、項目が 1 つの場合だけです +@msg an XPath node test is missing XPath のノードテストがありません +@msg an XPath operand is missing XPath のオペランドがありません +@msg an XPath operator is missing XPath の演算子がありません +@msg an XPath predicate or step cannot follow here ここに XPath の述語やステップを続けることはできません +@msg an XPath predicate or step follows a value that is not a node-set XPath の述語またはステップがノードセットではない値の後にあります +@msg an XPath step is missing XPath のステップがありません +@msg an image couldn't be decoded and is missing from the picture 画像をデコードできなかったため、図に含まれていません +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) {w} × {h} ピクセルの画像は大きすぎて変換できません (最大 {_1} ピクセル) +@msg apply was called on a value that is not a function apply が関数ではない値に対して呼び出されました +@msg area type in a thread cannot use Dynamic Text: {frames} スレッド内のエリア内文字ではダイナミックテキストを使用できません: {frames} +@msg array literal too long 配列リテラルが長すぎます +@msg assignment to a constant binding 定数バインディングへの代入です +@msg at least one row that isn't excluded needs a weight above 0 除外されていない行の少なくとも 1 つに 0 より大きいウェイトが必要です +@msg bad point {v}: expected [x, y] 点 {v} が正しくありません: [x, y] を指定してください +@msg blend must be a mode name blend には描画モードの名前を指定してください +@msg both sides of an XPath | must select nodes XPath の | の両辺でノードを選択する必要があります +@msg canceled キャンセルしました +@msg cannot convert the object to a primitive value オブジェクトをプリミティブ値に変換できません +@msg cannot convert undefined or null to an object undefined または null をオブジェクトに変換できません +@msg character dimensions cannot be represented with the source text transform 文字の寸法をソースのテキスト変形で表すことができません +@msg character dimensions cannot be represented with the target text transform 文字の寸法をターゲットのテキスト変形で表すことができません +@msg continuation must be an array of path chunks continuation にはパスチャンクの配列を指定してください +@msg continuation requires points continuation には点が必要です +@msg each path chunk must contain 1 to 4096 points 各パスチャンクには 1~4096 個の点が必要です +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) 各指定は {{at: [x, y]}} または {{rect: [x0, y0, x1, y1]}} です (指定値: {v}) +@msg each weight must be a number of 0 or more (got {v}) 各ウェイトには 0 以上の数値を指定してください (指定値: {v}) +@msg finally table {t} out of range finally テーブル {t} が範囲外です +@msg flags cannot be given with a RegExp object RegExp オブジェクトと一緒にフラグを指定することはできません +@msg function declaration index out of range 関数宣言のインデックスが範囲外です +@msg give `char`, `text`, `code` or `unicode` `char`、`text`、`code`、`unicode` のいずれかを指定してください +@msg give `count` or `spacing`, not both `count` または `spacing` のどちらか一方を指定してください +@msg give `name`, an SVG filter's name SVG フィルターの名前を `name` に指定してください +@msg give `path`, an SVG file, or `svg`, SVG text SVG ファイルを `path` に、または SVG テキストを `svg` に指定してください +@msg give `xml`, one SVG element SVG の 要素を 1 つ `xml` に指定してください +@msg give exactly one of `at`, `rect` and `points` `at`、`rect`、`points` のうち 1 つだけを指定してください +@msg give exactly one of `path`, `data` and `dataBase64` `path`、`data`、`dataBase64` のうち 1 つだけを指定してください +@msg give index or indexes, not both index または indexes のどちらか一方を指定してください +@msg give one element 要素を 1 つ指定してください +@msg give opacity or blend opacity または blend を指定してください +@msg handler at {_1} expects {_2} values but its range starts with {d} {_1} のハンドラーは {_2} 個の値を想定していますが、範囲の開始時は {d} 個です +@msg handler depth too large ハンドラーの深さが大きすぎます +@msg handler range {_1}..{_2} out of range ハンドラーの範囲 {_1}..{_2} が範囲外です +@msg hiddenLayers must be true or false hiddenLayers には true または false を指定してください +@msg hvcC hvcC +@msg images in a spot ink whose samples couldn't be read were imported in RGB サンプルを読み取れなかった特色インキの画像は RGB で読み込みました +@msg images past the most pixels one file may hold were left out 1 つのファイルに含められる最大ピクセル数を超えた画像は除外しました +@msg images too large to read were left out 大きすぎて読み込めない画像は除外しました +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes に指定できるエントリは最大 {MAX_GUIDE_INDEXES} 個です +@msg indexes must be an array of guide indexes indexes にはガイドのインデックスの配列を指定してください +@msg instruction out of range 命令が範囲外です +@msg instruction {pc} ({op}) pops below the stack 命令 {pc} ({op}) がスタックの底を超えて取り出しています +@msg instruction {pc} reached with stack depths {seen} and {d} 命令 {pc} にスタックの深さ {seen} と {d} で到達しました +@msg invalid array length 配列の長さが無効です +@msg invalid group グループが無効です +@msg invalid object id {v}: expected a non-negative integer オブジェクト ID {v} が無効です: 0 以上の整数を指定してください +@msg invalid regular expression flag '{_1}' 正規表現のフラグ '{_1}' が無効です +@msg invalid regular expression: {m} 正規表現が無効です: {m} +@msg invalid time value 時刻の値が無効です +@msg is a confirm's answer, whose `value` must be true or false は confirm の回答で、`value` には true または false を指定してください +@msg is a prompt's answer, whose `value` must be a string or null は prompt の回答で、`value` には文字列または null を指定してください +@msg is an alert's answer, whose `value` must be "ok" は alert の回答で、`value` には "ok" を指定してください +@msg is longer than 16 KiB が 16 KiB を超えています +@msg it draws its image through a clipping path クリッピングパスを通して画像を描画しています +@msg it draws no image 画像を描画していません +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes {MAX_DICTIONARY_FILE_BYTES} バイトを超えています +@msg it isn't a file ファイルではありません +@msg it names {_1} plates, and at most {MAX_PLATES} are read {_1} 個の版が指定されていますが、読み込めるのは最大 {MAX_PLATES} 個です +@msg its art is too long or too detailed for a head アートが長すぎるか細かすぎるため、矢印にできません +@msg its art paints nothing アートが何も描画しません +@msg its image is too large to read 画像が大きすぎて読み込めません +@msg its plates are too large: more than {MAX_SAMPLES} pixels together 版が大きすぎます: 合計で {MAX_SAMPLES} ピクセルを超えています +@msg its plates are too large: {_1} plates of {w} × {h} pixels 版が大きすぎます: {w} × {h} ピクセルの版が {_1} 個あります +@msg its plates can't be kept together 版をまとめて保持できません +@msg its shapes could not be combined into one シェイプを 1 つに結合できませんでした +@msg jump to {pc}, past the end 末尾を超えた {pc} へのジャンプです +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding には {_1} のいずれかを指定してください +@msg localize: the first argument must be a localization object or a string localize: 最初の引数にはローカライズオブジェクトまたは文字列を指定してください +@msg missing `picks` `picks` がありません +@msg missing `word` `word` がありません +@msg must be an object with `kind` and `value` には `kind` と `value` を持つオブジェクトを指定してください +@msg must have `kind` alert, confirm or prompt の `kind` には alert、confirm、prompt のいずれかを指定してください +@msg must have exactly `kind` and `value` には `kind` と `value` だけを指定してください +@msg needs a closing quote after the name 名前の後に閉じ引用符が必要です +@msg needs a name 名前が必要です +@msg needs a name of at most 64 bytes 64 バイト以内の名前が必要です +@msg needs a name of letters, digits and _, or a quoted name 文字、数字、_ からなる名前、または引用符で囲んだ名前が必要です +@msg needs a name without control characters 制御文字を含まない名前が必要です +@msg nesting too deep 入れ子が深すぎます +@msg no HP-GL format HP-GL 形式はありません +@msg no New Document profile called `{shown}` ({_1}) `{shown}` という新規ドキュメントプロファイルはありません ({_1}) +@msg no SVG filter at index {k} インデックス {k} に SVG フィルターはありません +@msg no arrowheads file here ここに矢印ファイルはありません +@msg no character has code {code} コード {code} の文字はありません +@msg no custom arrowhead {name} (see stroke.arrowheads.list) カスタム矢印 {name} はありません (stroke.arrowheads.list を参照) +@msg no file to open 開くファイルがありません +@msg no instructions 命令がありません +@msg no layer to rename 名前を変更するレイヤーがありません +@msg no random numbers ({e}) 乱数を取得できません ({e}) +@msg no selected or given object has a visible 3D effect 選択または指定したオブジェクトに、表示されている 3D 効果がありません +@msg no such artboard {v} アートボード {v} はありません +@msg no symbol named `{_1}` `{_1}` という名前のシンボルはありません +@msg node {_1} already has the text variable `{_2}` ノード {_1} にはすでにテキスト変数 `{_2}` があります +@msg not a DCS file DCS ファイルではありません +@msg not a RegExp object RegExp オブジェクトではありません +@msg not a regular expression 正規表現ではありません +@msg not an action set: the text doesn't start with /version and a number アクションセットではありません: テキストが /version と数値で始まっていません +@msg nothing to repeat 繰り返す対象がありません +@msg numbers out of order in {_1} quantifier {_1} 量指定子の数値の順序が正しくありません +@msg object {_1} needs a 3D effect オブジェクト {_1} には 3D 効果が必要です +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic オブジェクト {_1}: 中空の「押し出しとベベル」にはキャップがありません: グラフィックをマッピングするには「キャップ」をオンにしてください +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front オブジェクト {_1}: 奥行きが 0 のオブジェクトは面が 1 つです: グラフィックは前面にマッピングしてください +@msg object {_1}: {_2} オブジェクト {_1}: {_2} +@msg object {_1}: {e} オブジェクト {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams オブジェクト {_1}: インデックス {index} の {name} には照明プリセットがありません。ライトは effect.setParams で設定してください +@msg opacity must be a number opacity には数値を指定してください +@msg operand too large オペランドが大きすぎます +@msg order is a whole number from 0 to {others}, not `{v}` order には 0 から {others} までの整数を指定してください (`{v}` は指定できません) +@msg path chunks must share their boundary point パスの分割部分は境界点を共有している必要があります +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false の場合、useArtboards が false のときは、編集可能なアートワークを保持するためにアートボードが必要です +@msg point x must be a number 点の x には数値を指定してください +@msg point y must be a number 点の y には数値を指定してください +@msg points must be [x,y] pairs points には [x,y] の組を指定してください +@msg points must be finite canvas points points にはキャンバス上の有限の点を指定してください +@msg range out of order in character class 文字クラス内の範囲の順序が正しくありません +@msg reduce of an empty array with no initial value 初期値のない空の配列に対する reduce です +@msg regular expression flag '{_1}' given twice 正規表現のフラグ '{_1}' が 2 回指定されています +@msg sampled text would exceed the canvas (coordinates out of range) サンプリングしたテキストがキャンバスを超えます (座標が範囲外) +@msg select an SVG filter SVG フィルターを選択してください +@msg select an object with a 3D effect 3D 効果のあるオブジェクトを選択してください +@msg select an object with a 3D effect, or give its id in ids 3D 効果のあるオブジェクトを選択するか、その ID を ids に指定してください +@msg select one layer, group or object to rename 名前を変更するレイヤー、グループ、オブジェクトのいずれかを 1 つ選択してください +@msg select the art to weed カス取りするアートを選択してください +@msg select type on a path or area type パス上文字またはエリア内文字を選択してください +@msg side is left or right, not `{v}` side には left または right を指定してください (`{v}` は指定できません) +@msg source text transform is collapsed or unrepresentable 元のテキストの変形がつぶれているか、表現できません +@msg table must be true or false table には true または false を指定してください +@msg takes one name 名前を 1 つ指定してください +@msg target text transform is collapsed or unrepresentable 対象のテキストの変形がつぶれているか、表現できません +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer 分割数 (最大 100000) またはダッシュの段数が多くなりすぎます: 破線パターンを単純にするか、ダッシュを長くしてください +@msg the Layers panel is not shown レイヤーパネルが表示されていません +@msg the Layers panel's search, filter or Show Layers Only hides its row レイヤーパネルの検索、フィルター、「レイヤーのみを表示」のいずれかでその行が隠れています +@msg the Objects on Path group is hidden or locked 「パス上のオブジェクト」グループが非表示またはロックされています +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes SVG ファイルが {MAX_IMPORT_BYTES} バイトを超えています +@msg the XML operation needs an XML or XMLList value XML 操作には XML または XMLList の値が必要です +@msg the XPath expression does not evaluate to a node-set XPath 式の評価結果がノードセットではありません +@msg the XPath expression ends early XPath 式が途中で終わっています +@msg the XPath expression has an unmatched ) XPath 式に対応していない ) があります +@msg the XPath expression has an unmatched ] XPath 式に対応していない ] があります +@msg the XPath expression has an unmatched closing bracket XPath 式に対応していない閉じ括弧があります +@msg the XPath expression selects a namespace node, which is not an XML value XPath 式が名前空間ノードを選択していますが、これは XML の値ではありません +@msg the XPath expression selects the root node, which is not an XML value XPath 式がルートノードを選択していますが、これは XML の値ではありません +@msg the XPath function {name} needs a node-set XPath 関数 {name} にはノードセットが必要です +@msg the XPath function {name} takes the wrong number of arguments XPath 関数 {name} の引数の数が正しくありません +@msg the XPath name functions need a node-set XPath の名前関数にはノードセットが必要です +@msg the XPath prefix {shown} is not bound to a namespace XPath の接頭辞 {shown} は名前空間に結び付けられていません +@msg the XPath variable ${shown} is not bound XPath 変数 ${shown} は束縛されていません +@msg the argument list is not an object 引数リストがオブジェクトではありません +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out アートワークが EPS には大きすぎます: PostScript の 256 MB より後の部分は除外しました +@msg the bitmaps hold more pixels than one file may ビットマップのピクセル数が 1 つのファイルに含められる数を超えています +@msg the code runs past its last instruction コードが最後の命令を超えて実行されます +@msg the curve tolerance must be from 0.01 to 10 points 曲線の許容値には 0.01 から 10 ポイントまでの値を指定してください +@msg the custom dictionary couldn't be saved to {_1}: {e} カスタム辞書を {_1} に保存できませんでした: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is カスタム辞書 {_1} を読み込めないため ({why})、そのまま残しました +@msg the document already has an SVG filter named {name} ドキュメントにはすでに {name} という名前の SVG フィルターがあります +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it ドキュメントには {name} という名前の別の SVG フィルターがあります: svgFilter.apply を使用するか、編集するには `replace` を付けて svgFilter.define を使用してください +@msg the document has no SVG filter named {name} ドキュメントに {name} という名前の SVG フィルターはありません +@msg the document has no SVG filter named {r} ドキュメントに {r} という名前の SVG フィルターはありません +@msg the document has no color swatches ドキュメントにカラースウォッチがありません +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 ドキュメントのスウォッチには {_1} 色ありますが、ドキュメント減色で使用できるのは最大 255 色です +@msg the file isn't valid SVG: {e} ファイルが有効な SVG ではありません: {e} +@msg the file names no plate ファイルに版が指定されていません +@msg the function's prototype is not an object 関数のプロトタイプがオブジェクトではありません +@msg the guide is hidden or locked ガイドが非表示またはロックされています +@msg the library's file is outside the read roots ライブラリのファイルが読み取りルートの外にあります +@msg the line is too long, or the paths it passes too many, to check 線が長すぎるか、通過するパスが多すぎるため、確認できません +@msg the method needs an XML value このメソッドには XML の値が必要です +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) ペイントがフリーフォームグラデーションではありません (paint.editGradient {kind} で適用してください) +@msg the parameters or body do not form one function パラメーターまたは本体が 1 つの関数になっていません +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it ペーストした SVG フィルター {name} は、ドキュメントにある同名のフィルターと描画結果が異なり、使用できる空き名もありません +@msg the paths are too many or too tangled to find where they cross パスが多すぎるか複雑に絡み合っているため、交差する位置を見つけられません +@msg the pen table holds more than 255 pens ペンテーブルに 255 本を超えるペンがあります +@msg the plates can't be kept 版を保持できません +@msg the position table does not match the instructions 位置テーブルが命令と一致しません +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes prompt のテキストが {MAX_PROMPT_BYTES} バイトを超えています +@msg the radix must be between 2 and 36 基数には 2 から 36 までの値を指定してください +@msg the rectangle or line reaches too many paths to check 長方形または線にかかるパスが多すぎるため、確認できません +@msg the right side of 'in' is not an object 'in' の右辺がオブジェクトではありません +@msg the right side of 'instanceof' is not a function 'instanceof' の右辺が関数ではありません +@msg the selected art is nested more than {MAX_DEPTH} levels deep 選択したアートの入れ子が {MAX_DEPTH} 階層を超えています +@msg the selected art is too large to measure 選択したアートが大きすぎて計測できません +@msg the selection has no outlines to weed around 選択範囲に、カス取りの対象となるアウトラインがありません +@msg the value bind was called on is not a function bind の呼び出し対象の値が関数ではありません +@msg the value is not a constructor 値がコンストラクターではありません +@msg the value is not a function 値が関数ではありません +@msg the value is not a valid XML name 値が有効な XML 名ではありません +@msg the value is not an XML attribute name 値が XML 属性名ではありません +@msg the value is not an XML name 値が XML 名ではありません +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines カス取りラインがアートのアウトラインと交差する回数が多すぎて計測できません。ラインの数を減らしてください +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap カス取りラインに近すぎるアウトラインのセグメントが多すぎて計測できません。ラインの数を減らすか、間隔を小さくしてください +@msg the weeding lines would be more than {max_pieces} pieces カス取りラインが {max_pieces} 個を超えます +@msg the {_1} plate lies outside the file {_1} 版がファイルの外にあります +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that これはバイナリ DXF ファイルのため読み込めません: CAD アプリで ASCII DXF として保存し、そのファイルを開いてください +@msg this warp's points can't be edited: use Reset with Mesh first このワープのポイントは編集できません: 先に「メッシュでリセット」を使用してください +@msg toExponential() digits must be between 0 and 20 toExponential() の桁数には 0 から 20 までの値を指定してください +@msg toFixed() digits must be between 0 and 20 toFixed() の桁数には 0 から 20 までの値を指定してください +@msg toISOString is not a function toISOString は関数ではありません +@msg toPrecision() argument must be between 1 and 21 toPrecision() の引数には 1 から 21 までの値を指定してください +@msg toSource: a getter cannot be written as source toSource: ゲッターはソースとして書き出せません +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: 要素以外のプロパティ、列挙可能でないプロパティ、または別のプロトタイプを持つ配列は、ソースとして書き出せません +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: 列挙可能でないプロパティを持つオブジェクトや、プロトタイプが Object.prototype ではないオブジェクトは、ソースとして書き出せません +@msg toSource: the source is too long toSource: ソースが長すぎます +@msg toSource: the value contains itself toSource: 値が自身を含んでいます +@msg toSource: the value has too many entries toSource: 値のエントリが多すぎます +@msg toSource: the value is nested too deep toSource: 値の入れ子が深すぎます +@msg toString is not a function toString は関数ではありません +@msg too many arguments 引数が多すぎます +@msg too many quantifiers 量指定子が多すぎます +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) `{t}` は不明な OpenType 機能です (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt。オフにするには先頭に - を付けます。swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt には、`salt=2` のように `tag=N` の形式で代替字形の番号を指定できます。N は 0 から {_1} まで) +@msg unknown dither `{d}` (diffusion, pattern, noise or none) `{d}` は不明なディザの指定です (diffusion、pattern、noise、none のいずれか) +@msg unknown panel `{panel}` `{panel}` は不明なパネルです +@msg unknown parameter `{key}` `{key}` は不明なパラメーターです +@msg unknown parameter `{within}settings.{format}`; {why} `{within}settings.{format}` は不明なパラメーターです。{why} +@msg unknown parameter `{within}{key}`; {why} `{within}{key}` は不明なパラメーターです。{why} +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) `{reduction}` は不明な減色の指定です (selective、perceptual、adaptive、web、gray、blackWhite、uniform、document のいずれか) +@msg unmatched ')' 対応していない ')' があります +@msg unterminated character class 文字クラスが閉じられていません +@msg unterminated group グループが閉じられていません +@msg wordGap and lineGap need area type in the selection wordGap と lineGap を使用するには、選択範囲にエリア内文字が必要です +@msg xpath needs its variables as an object xpath の変数にはオブジェクトを指定してください +@msg xpath needs the expression as a string xpath の式には文字列を指定してください +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}。また、設定とキャッシュを {_1} から {_2} に戻せません: {e} +@msg {_1} is not a folder: {e} {_1} はフォルダーではありません: {e} +@msg {_1}: {_2} and {_3} are in the way {_1}: {_2} と {_3} が妨げになっています +@msg {code}: {0} {code}: {0} +@msg {dir}: {e} {dir}: {e} +@msg {file} names more than one linked JavaScript file {file} にリンクされた JavaScript ファイルが複数指定されています +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: カスタム矢印のパスには塗りつぶせる領域が必要で、アンカーは最大 {most} 個、サブパスは最大 {most} 個、各アンカーは先端から矢印単位で {far} 以内に収めてください +@msg {k}: bad custom arrowhead path: {e} {k}: カスタム矢印のパスが正しくありません: {e} +@msg {k}: bad custom arrowhead: {e} {k}: カスタム矢印が正しくありません: {e} +@msg {k}: custom must be a name {k}: custom には名前を指定してください +@msg {name} must be a number {name} には数値を指定してください +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy で DCS ファイルを使用できるのは、版がファイル内にある場合だけです。版ファイルのある場所でそのファイルに再リンクしてください +@msg {path}: larger than {_1} MB {path}: {_1} MB を超えています +@msg {shown} is not an XPath 1.0 function {shown} は XPath 1.0 の関数ではありません +@msg {v} names no layer or sublayer {v} に該当するレイヤーまたはサブレイヤーはありません +@msg {what} is not a function {what} は関数ではありません +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what} では 1 枚の用紙に {MAX_COPIES_PER_SHEET} 部を超えてプリントされます: 拡大してください +@msg {what} {i} out of range {what} {i} が範囲外です +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. 1 つのプロファイルに適用できる回転体効果は 1 つです。既存の効果のオプションを編集してください。 +@msg Add Points at Crossings: a path went missing 交差点にポイントを追加: パスが見つからなくなりました +@msg CSS options: {e} CSS オプション: {e} +@msg CSV has a header but no data rows CSV にヘッダーはありますが、データ行がありません +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV に NUL バイトが含まれています: UTF-8、またはバイトオーダーマーク付きの UTF-16 で保存してください +@msg CSV is empty CSV が空です +@msg CSV is larger than {MAX_CSV_BYTES} bytes CSV が {MAX_CSV_BYTES} バイトを超えています +@msg CSV is not UTF-16 CSV が UTF-16 ではありません +@msg CSV is not UTF-8 CSV が UTF-8 ではありません +@msg CSV would be larger than {MAX_CSV_BYTES} bytes CSV が {MAX_CSV_BYTES} バイトを超えます +@msg Choose original artwork, not a dimension's generated paths 寸法から生成されたパスではなく、元のアートワークを選択してください +@msg Contour: select paths or shapes 輪郭: パスまたはシェイプを選択してください +@msg Contour: the Offset Path effect is not available 輪郭: 「パスのオフセット」効果を使用できません +@msg Create needs fills, strokes or both 「作成」には塗り、線、またはその両方が必要です +@msg DXF options: {e} DXF オプション: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} DXF の拡大・縮小率には {_1} 以下の正の数値を指定してください ({scale} は指定できません) +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Dimension is locked or hidden 寸法がロックされているか非表示です +@msg Dimension mode does not match its source 寸法のモードが元のオブジェクトと一致しません +@msg Draw Inside needs exactly one selected path, compound path or text 「内側描画」を使用するには、パス、複合パス、テキストのいずれかを 1 つだけ選択してください +@msg EPS Save writes all artwork: use document.export for selected-only EPS EPS の保存ではすべてのアートワークを書き出します: 選択部分だけの EPS には document.export を使用してください +@msg EPS flattener: {e} EPS の分割・統合: {e} +@msg EPS options: {e} EPS オプション: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft 「スクリーン用に書き出し」で書き出せるのは png、png8、jpg、webp、gif、tiff、bmp、tga、psd、svg、svgz、pdf、eps、ai、vectorcraft です +@msg Flare click coordinates must be finite numbers フレアのクリック座標には有限の数値を指定してください +@msg HTML Text applies to object slices of type only 「HTML テキスト」を適用できるのは文字のオブジェクトスライスだけです +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) 「オブジェクトの識別方法」が XML ID になっています: 名前には有効な XML ID を指定してください (先頭は文字、アンダースコア、コロンのいずれか。スペースは使用できません) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) 画像トレースに失敗しました: 内部エラー: スレッドが停止しました (このバグを報告してください) +@msg Image Trace failed: {e} 画像トレースに失敗しました: {e} +@msg Image Trace finished, but its document was closed 画像トレースは完了しましたが、ドキュメントが閉じられています +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again 画像トレースは完了しましたが、その間に画像または設定が変更されました: もう一度トレースしてください +@msg Invalid dimension offset 寸法のオフセットが無効です +@msg Invalid point 点が無効です +@msg Join: a subpath went missing 連結: サブパスが見つからなくなりました +@msg Lock Workspace is not available in VectorCraft 「ワークスペースをロック」は VectorCraft では使用できません +@msg Move Panel to New Window is not available: floating panels stay in the main window 「パネルを新規ウィンドウに移動」は使用できません: フローティングパネルはメインウィンドウ内に表示されます +@msg Offset Path: the path is too complex to offset パスのオフセット: パスが複雑すぎてオフセットできません +@msg Offset Path: {e} パスのオフセット: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported PDF の内容が不完全か、読み込み機能の上限を超えています。ドキュメントは読み込まれませんでした +@msg PDF flattener: {e} PDF の分割・統合: {e} +@msg PDF options: {e} PDF オプション: {e} +@msg Recolor Artwork is no longer open 「アートワークを再配色」はすでに閉じられています +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG が UTF-8、UTF-16、ISO 8859-1 のいずれでもありません +@msg Save writes one artboard: name one, or export several with document.export 保存で書き出せるアートボードは 1 つです: 1 つ指定するか、複数の場合は document.export で書き出してください +@msg Select a dimension first 先に寸法を選択してください +@msg Select dimensions to update 更新する寸法を選択してください +@msg Select only dimensions 寸法だけを選択してください +@msg Select two or more objects, or a group 2 つ以上のオブジェクト、またはグループを選択してください +@msg Shaper needs 1–64 source paths Shaper には 1~64 個の元パスが必要です +@msg Switch x/y is for scatter graphs 「X/Y を入れ替え」は散布図用です +@msg Text options: {e} テキストオプション: {e} +@msg `align` is start, center or end `align` には start、center、end のいずれかを指定してください +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` には [subpath, anchor] の組の配列を指定してください +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` にはアンカーのインデックス (0, 1, 2…) を指定してください ({k} は指定できません) +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` にはアンカーのインデックス (0, 1, 2…) を指定してください ({v} は指定できません) +@msg `artboards` must be a list of artboard indices `artboards` にはアートボードのインデックスのリストを指定してください +@msg `asset` is the asset id (see assets.list) `asset` にはアセット ID を指定してください (assets.list を参照) +@msg `at` must be a byte offset `at` にはバイトオフセットを指定してください +@msg `autoLeading` must be a number `autoLeading` には数値を指定してください +@msg `background` must be "", "matte" or #rrggbb `background` には ""、"matte"、#rrggbb のいずれかを指定してください +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` には "none"、"standard"、"forced" のいずれかを指定してください +@msg `center` must be [x, y] `center` には [x, y] を指定してください +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` には "romanBaseline"、"emBoxTop"、"emBoxCenter"、"emBoxBottom"、"icfTop"、"icfBottom" のいずれかを指定してください +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` には "default"、"leftToRight"、"rightToLeft" のいずれかを指定してください +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke` には `stroke: false` と `ids: []` が必要で、`stop` は指定できません +@msg `clear` must be a list of type object ids, not {v} `clear` には文字オブジェクトの ID のリストを指定してください ({v} は指定できません) +@msg `clear` must list type objects; {_1} is not one `clear` には文字オブジェクトを指定してください。{_1} は文字オブジェクトではありません +@msg `clear` must not list `id` `clear` に `id` を含めることはできません +@msg `color` needs a `rule` to make the theme from `color` には、テーマの作成に使用する `rule` が必要です +@msg `colors` is empty `colors` が空です +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` には vertical、uniform、repeating、sliding のいずれかを指定してください +@msg `columns` must be a whole number 1..50 `columns` には 1..50 の整数を指定してください +@msg `commands` must not be empty `commands` を空にすることはできません +@msg `contents` goes with `to` `contents` は `to` と一緒に指定してください +@msg `corners` must be sharp, round or square, not `{other}` `corners` には sharp、round、square のいずれかを指定してください (`{other}` は指定できません) +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` にはアンカーのインデックス (0, 1, 2…) を指定してください ({v} は指定できません) +@msg `count` must be a whole number from 1 to 100 `count` には 1 から 100 までの整数を指定してください +@msg `dataBase64` is larger than the module limit `dataBase64` がモジュールの上限を超えています +@msg `dataBase64` is not valid base64 `dataBase64` が有効な base64 ではありません +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` は `ids`、`reverse`、`append` と同時に使用できません +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` と `childLimit` はサマリーを絞り込みます: `summary: true` を指定してください +@msg `design` is a design name, or null for plain columns `design` にはデザイン名を指定します。通常の棒にするには null を指定します +@msg `design` is a design name, or null for the default marker `design` にはデザイン名を指定します。初期設定のマーカーにするには null を指定します +@msg `destinationFolder` is required when destination is `folder` destination が `folder` の場合は `destinationFolder` が必要です +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` には `none`、`saveAndClose`、`folder` のいずれかを指定してください +@msg `diameter` must be from 0.1 to 2500 pt `diameter` には 0.1 から 2500 pt までの値を指定してください +@msg `digits` must be one of {_1} `digits` には {_1} のいずれかを指定してください +@msg `dimImages` is a percentage or false `dimImages` にはパーセント値または false を指定してください +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` には "auto"、"leftToRight"、"rightToLeft" のいずれかを指定してください +@msg `each` is the value one design stands for, more than 0 `each` にはデザイン 1 つが表す値を 0 より大きい数で指定してください +@msg `end` must be "start" or "end" `end` には "start" または "end" を指定してください +@msg `ends` applies to joining exactly two paths `ends` を使用できるのは、ちょうど 2 つのパスを連結する場合だけです +@msg `event` must be one of {_1} `event` には {_1} のいずれかを指定してください +@msg `every` must be "all", "odd" or "even", not {v} `every` には "all"、"odd"、"even" のいずれかを指定してください ({v} は指定できません) +@msg `expand` must be true or false `expand` には true または false を指定してください +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` には、直前の text.findFontFiles の検索で見つかったフォントファイルのパスを 1 ~ 1000 個指定してください +@msg `folder` must be an absolute folder path `folder` にはフォルダーの絶対パスを指定してください +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` には、{"wght"} のような軸タグと値のオブジェクトを指定してください +@msg `format` must be a string `format` には文字列を指定してください +@msg `format` must be one of {_1} `format` には {_1} のいずれかを指定してください +@msg `fractions` is chop or scale `fractions` には chop または scale を指定してください +@msg `from` and `to` must differ `from` と `to` には異なる値を指定してください +@msg `from` has an empty frame `from` に空のフレームがあります +@msg `from` must be a non-negative integer `from` には 0 以上の整数を指定してください +@msg `from` must be area type or type on a path `from` にはエリア内文字またはパス上文字を指定してください +@msg `gid` must be a glyph id, not {v} `gid` にはグリフ ID を指定してください ({v} は指定できません) +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` は 1 つの文字に設定するもので、文字スタイルには設定できません (text.setGlyphAlternate で設定します) +@msg `guide` must be a guide index (guide.list) `guide` にはガイドのインデックスを指定してください (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` には default、left、center、right のいずれかを指定してください +@msg `id` must be a node id `id` にはノード ID を指定してください +@msg `id` must be an object id, not {v} `id` にはオブジェクト ID を指定してください ({v} は指定できません) +@msg `ids` must name Objects on Path groups or objects in them `ids` には「パス上のオブジェクト」グループ、またはその中のオブジェクトを指定してください +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` に指定できる ID は最大 {MAX_INSPECT_IDS} 個です +@msg `imageMap` must be none, rectangle or polygon `imageMap` には none、rectangle、polygon のいずれかを指定してください +@msg `includeArtboardFill` must be true or false `includeArtboardFill` には true または false を指定してください +@msg `includeSubdirs` must be true or false `includeSubdirs` には true または false を指定してください +@msg `index` must be a point index `index` にはポイントのインデックスを指定してください +@msg `index` must be a whole number or null `index` には整数または null を指定してください +@msg `indices` must be a non-empty array of item indices `indices` には項目のインデックスを 1 つ以上含む配列を指定してください +@msg `items` is a list of {kind, item} `items` には {kind, item} のリストを指定してください +@msg `items` is empty `items` が空です +@msg `items` must be a list of {id, points} `items` には {id, points} のリストを指定してください +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` には {{id, anchors?}} のリストを指定してください ({v} は指定できません) +@msg `items` must be an array of {id, anchors} `items` には {id, anchors} の配列を指定してください +@msg `items` must be an array of {{id, corners?}}, not {v} `items` には {{id, corners?}} の配列を指定してください ({v} は指定できません) +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` には "none"、"short"、"medium"、"long"、"stylistic" のいずれかを指定してください +@msg `kind` is one of {_1} `kind` には {_1} のいずれかを指定してください +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` には "round"、"invertedRound"、"chamfer" のいずれかを指定してください ({v} は指定できません) +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` には `text`、`visibility`、`image`、`graph` のいずれかを指定してください +@msg `kind` must be a string `kind` には文字列を指定してください +@msg `kind` must be image, noImage or htmlText `kind` には image、noImage、htmlText のいずれかを指定してください +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` には "none"、"hard"、"soft" のいずれかを指定してください +@msg `label` must be a non-empty name of at most 200 characters `label` には 200 文字以内の空でない名前を指定してください +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` には、"Turkish" や "tr" のような言語名またはタグを指定してください +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` には "romanBaseline" または "emBoxTop" を指定してください +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` には 0.1 から 5000 までの値を指定してください。自動にするには null を指定します +@msg `length` must be a number of at most 1000000 pt either way `length` には正負いずれの方向でも 1000000 pt 以内の数値を指定してください +@msg `line` must be true or false `line` には true または false を指定してください +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) を `maxAngle` ({max_angle}°) より大きくすることはできません +@msg `mode` must be "handles", "straight" or "smooth" `mode` には "handles"、"straight"、"smooth" のいずれかを指定してください +@msg `mode` must be "straight", "bezier" or "arc" `mode` には "straight"、"bezier"、"arc" のいずれかを指定してください +@msg `mode` must be one of {_1} `mode` には {_1} のいずれかを指定してください +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` には "none"、"yakumonoHankaku"、"gyomatsuYakumonoHankaku"、"gyomatsuYakumonoZenkaku"、"yakumonoZenkaku"、"lineEndHalf" のいずれかを指定してください +@msg `names` is a list of preset names `names` にはプリセット名のリストを指定してください +@msg `names` lists preset names `names` にはプリセット名を指定してください +@msg `names` must be a list of names `names` には名前のリストを指定してください +@msg `names` must be a list of swatch names `names` にはスウォッチ名のリストを指定してください +@msg `names` must be an array of style names `names` にはスタイル名の配列を指定してください +@msg `new` is a design name, or true `new` にはデザイン名または true を指定してください +@msg `path` must be a folder `path` にはフォルダーを指定してください +@msg `path` must be a path object `path` にはパスオブジェクトを指定してください +@msg `pivot` must be a whole number from 0 to 8 `pivot` には 0 から 8 までの整数を指定してください +@msg `points` must be a list of [x, y] points `points` には [x, y] の点のリストを指定してください +@msg `points` must be a list of point indexes `points` にはポイントのインデックスのリストを指定してください +@msg `points` must be an array of point indices `points` にはポイントのインデックスの配列を指定してください +@msg `points` must be point indexes `points` にはポイントのインデックスを指定してください +@msg `port` must be "in" or "out" `port` には "in" または "out" を指定してください +@msg `position` is above or below `position` には above または below を指定してください +@msg `preset` must be a string `preset` には文字列を指定してください +@msg `ratio` must be from 0.1 to 0.5 `ratio` には 0.1 から 0.5 までの値を指定してください +@msg `rect` must be [x, y, width, height] with a positive size `rect` には、サイズが正の [x, y, width, height] を指定してください +@msg `relation` must be overlap, inside or outside `relation` には overlap、inside、outside のいずれかを指定してください +@msg `reversed` must be true or false `reversed` には true または false を指定してください +@msg `rotate` must be a finite number `rotate` には有限の数値を指定してください +@msg `rule` must be nonZero or evenOdd `rule` には nonZero または evenOdd を指定してください +@msg `scale` must be a number in (0, 100] `scale` には (0, 100] の範囲の数値を指定してください +@msg `scope` must be "selection" or "document", not {other} `scope` には "selection" または "document" を指定してください ({other} は指定できません) +@msg `script` must be a string `script` には文字列を指定してください +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` には系列のインデックスを 1 つ以上含むリストを指定してください +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` には `type` または `valueAxis` が必要です +@msg `shift` must be a number of points `shift` にはポイント単位の数値を指定してください +@msg `showCenter` must be true or false `showCenter` には true または false を指定してください +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` には "fullJustify"、"alignLeft"、"alignCenter"、"alignRight" のいずれかを指定してください +@msg `size` is a percentage of the base text's size, 1 to 100 `size` には、親文字のサイズに対する割合を 1 から 100 までのパーセント値で指定してください +@msg `source` and `target` are the same object `source` と `target` が同じオブジェクトです +@msg `source` must be `folder` or `datasets` `source` には `folder` または `datasets` を指定してください +@msg `spacing` must be a number `spacing` には数値を指定してください +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` には 0 以上のポイント単位の数値、または "auto" を指定してください +@msg `start` must be a finite number `start` には有限の数値を指定してください +@msg `starts` must be an array of anchor indices (or nulls) `starts` にはアンカーのインデックス (または null) の配列を指定してください +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` には linked、embedded、missing、modified のいずれか、またはそのリストを指定してください +@msg `stop` must be a stop index `stop` には分岐点のインデックスを指定してください +@msg `story` styles the whole story: give no `start` or `end` `story` はストーリー全体にスタイルを設定します: `start` と `end` は指定しないでください +@msg `story` takes the whole story: give no `start` or `end` `story` はストーリー全体を対象にします: `start` と `end` は指定しないでください +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` は線に適用されます (stroke: true) +@msg `style` must be roman or japanese `style` には roman または japanese を指定してください +@msg `subpaths` must be two integer indices `subpaths` には整数のインデックスを 2 つ指定してください +@msg `symbol` must be a symbol name `symbol` にはシンボル名を指定してください +@msg `target` must be "object" or "contents", not {v} `target` には "object" または "contents" を指定してください ({v} は指定できません) +@msg `tint` applies to global and spot colours `tint` はグローバルカラーと特色に適用されます +@msg `tint` goes with a global or spot `swatch` `tint` はグローバルまたは特色の `swatch` と一緒に指定してください +@msg `tint` must be a number (%) `tint` には数値 (%) を指定してください +@msg `tint` needs a global or spot colour `tint` にはグローバルカラーまたは特色が必要です +@msg `to` is already threaded `to` はすでにスレッドされています +@msg `to` is the design's new name `to` にはデザインの新しい名前を指定してください +@msg `to` must be [x, y] `to` には [x, y] を指定してください +@msg `to` must be a closed path, area type or type on a path other than `from` `to` には、`from` 以外のクローズパス、エリア内文字、パス上文字のいずれかを指定してください +@msg `to` must be a whole number `to` には整数を指定してください +@msg `to` takes the rows of one object's stack, not aligned rows `to` には、整列した行ではなく、1 つのオブジェクトのスタック内の行を指定してください +@msg `useLab` must be true or false `useLab` には true または false を指定してください +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` には default、top、middle、baseline、bottom のいずれかを指定してください +@msg `values` must be a list `values` にはリストを指定してください +@msg `values` must be an object `values` にはオブジェクトを指定してください +@msg `values` must name hex, rgb, cmyk, lab or gray `values` には hex、rgb、cmyk、lab、gray のいずれかを指定してください +@msg `values` names a kind twice `values` に同じ種類が 2 回指定されています +@msg `{_1}` can't be edited or removed `{_1}` は編集も削除もできません +@msg `{_1}` is built in and always listed `{_1}` は組み込みで、常に一覧に表示されます +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` は、直前の text.findFontFiles の検索で見つかったフォントファイルではありません +@msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` がパッケージで上書きされます: 別のフォルダーまたは名前を選択してください +@msg `{file}` has no art to place `{file}` に配置できるアートがありません +@msg `{file}` is over {_1} MB `{file}` が {_1} MB を超えています +@msg `{f}` belongs to a VectorCraft that is running `{f}` は実行中の VectorCraft が使用しています +@msg `{f}` is the copy of a document open now `{f}` は現在開いているドキュメントのコピーです +@msg `{id}` is no writable format (see document.formats) `{id}` は書き出せる形式ではありません (document.formats を参照) +@msg `{id}` isn't a loaded library `{id}` は読み込まれているライブラリではありません +@msg `{key}` has no colours `{key}` にカラーがありません +@msg `{key}` is a design name `{key}` にはデザイン名を指定してください +@msg `{key}` is a design name or a list of them `{key}` にはデザイン名またはそのリストを指定してください +@msg `{key}` is longer than {MAX_NAME} characters `{key}` が {MAX_NAME} 文字を超えています +@msg `{key}` is out of range `{key}` が範囲外です +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` には [min, desired, max] または {{min?, desired?, max?}} をパーセントで指定してください +@msg `{key}` must be a non-negative integer `{key}` には 0 以上の整数を指定してください +@msg `{key}` must be a number {_1}..{_2} `{key}` には {_1}..{_2} の数値を指定してください +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` には 0 以上の整数を指定してください ({n} は指定できません) +@msg `{key}` must be an appearance item index (paint order) or null `{key}` にはアピアランス項目のインデックス (ペイント順) または null を指定してください +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` にはアセット ID の配列を指定してください (assets.list を参照) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` には 0 から 180 度までの値を指定してください ({a} は指定できません) +@msg `{key}` must be from {_1} to {shift_max} `{key}` には {_1} から {shift_max} までの値を指定してください +@msg `{key}`: not a colour: {v} `{key}`: カラーではありません: {v} +@msg `{k}.family` is required `{k}.family` は必須です +@msg `{k}` is a number 0..1 `{k}` には 0..1 の数値を指定してください +@msg `{k}` is true or false `{k}` には true または false を指定してください +@msg `{k}` isn't an export setting: {_1} `{k}` は書き出し設定ではありません: {_1} +@msg `{k}` must be a byte offset `{k}` にはバイトオフセットを指定してください +@msg `{k}` must be a number `{k}` には数値を指定してください +@msg `{k}` must be a number of points `{k}` にはポイント単位の数値を指定してください +@msg `{k}` must be a string `{k}` には文字列を指定してください +@msg `{k}` must be a whole number `{k}` には整数を指定してください +@msg `{k}` must be from {lo} to {hi} for {key} {key} の `{k}` には {lo} から {hi} までの値を指定してください +@msg `{k}` must be {what}, not {v} `{k}` には {what} を指定してください ({v} は指定できません) +@msg `{l}` isn't a global or spot colour `{l}` はグローバルカラーでも特色でもありません +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` はビットマップではありません: clipboard.importSvg、importPdf、importText のいずれかを使用してください +@msg `{name}` can't be deleted `{name}` は削除できません +@msg `{name}` can't be duplicated `{name}` は複製できません +@msg `{name}` can't be edited `{name}` は編集できません +@msg `{name}` can't be edited this way `{name}` はこの方法では編集できません +@msg `{name}` can't be renamed `{name}` は名前を変更できません +@msg `{name}` can't name a folder `{name}` にフォルダーは指定できません +@msg `{name}` has no art to place `{name}` に配置できるアートがありません +@msg `{name}` has no text to place `{name}` に配置できるテキストがありません +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text Word 文書内の `{name}` が UTF-8 または UTF-16 のテキストではありません +@msg `{name}` in the Word document is not well-formed XML: {e} Word 文書内の `{name}` が整形式の XML ではありません: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep Word 文書内の `{name}` で要素の入れ子が {MAX_NESTING} 階層を超えています +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` は組み込みのプリセットで、読み取り専用です: 別の名前で保存してください +@msg `{name}` is a built-in preset: save under another name `{name}` は組み込みのプリセットです: 別の名前で保存してください +@msg `{name}` is a built-in profile and stays `{name}` は組み込みのプロファイルのため、削除されません +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` は `{base}` の濃度違いです: `{base}` を編集してください (濃度スウォッチで変更できるのは名前だけです) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` は作業用スペースです: 先に edit.colorSettings で別のものを選択してください +@msg `{name}` is built in and stays `{name}` は組み込みのため、削除されません +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` には同じ種類のペイント (カラー、グラデーション、パターン) を指定してください +@msg `{n}` can't be deleted `{n}` は削除できません +@msg `{n}` can't be merged `{n}` は結合できません +@msg `{n}` can't be moved `{n}` は移動できません +@msg `{n}` is not a colour swatch `{n}` はカラースウォッチではありません +@msg `{n}` isn't a solid colour `{n}` は単色ではありません +@msg `{n}`: colour groups hold solid colours only `{n}`: カラーグループに入れられるのは単色だけです +@msg `{other}` doesn't trace an image `{other}` では画像をトレースできません +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: このステップの参照でコピーされる値が {_1} MB を超えています +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` は軸タグではありません: タグは "wght" のような 4 文字です +@msg `{to}` is empty or already used `{to}` が空か、すでに使用されています +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` が {MAX_TEXT} バイトを超えています +@msg `{what}` must be text `{what}` にはテキストを指定してください +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard DXF 図面の原点は 1 つです: アートボードごとに 1 つの図面にするには useArtboards: true を指定してください +@msg a PDF presets file with over {_1} presets PDF プリセットファイルに {_1} 個を超えるプリセットがあります +@msg a batch preview cannot switch documents バッチのプレビューではドキュメントを切り替えられません +@msg a brush named `{nn}` exists `{nn}` という名前のブラシがすでにあります +@msg a container of the listed objects is gone 指定したオブジェクトを含むコンテナがなくなりました +@msg a content stream can't be read コンテンツストリームを読み込めません +@msg a content stream isn't a stream コンテンツストリームがストリームではありません +@msg a design holds at most {_1} objects デザインに含められるオブジェクトは最大 {_1} 個です +@msg a design needs a name デザインには名前が必要です +@msg a dictionary grew too large 辞書が大きくなりすぎました +@msg a document keeps at most {_1} graph designs ドキュメントに保存できるグラフのデザインは最大 {_1} 個です +@msg a document keeps at most {_1} saved selections ドキュメントに保存できる選択範囲は最大 {_1} 個です +@msg a document keeps at most {_1} views ドキュメントに保存できる表示は最大 {_1} 個です +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files ドキュメントにリンクできる JavaScript ファイルは最大 {MAX_SVG_SCRIPTS} 個です +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes feConvolveMatrix フィルターの order が {x}×{y} で、usvg が行うカーネルサイズの乗算がオーバーフローします +@msg a gradient preset is already called {n} {n} という名前のグラデーションプリセットがすでにあります +@msg a graph has at most {_1} series, so it transposes with at most that many categories グラフの系列は最大 {_1} 個のため、行と列を入れ替えられるのは項目がその数以下の場合です +@msg a layer can't be merged into its own sublayer レイヤーを自身のサブレイヤーに結合することはできません +@msg a layer's form box isn't an array レイヤーのフォームのボックスが配列ではありません +@msg a layer's form can't be read レイヤーのフォームを読み込めません +@msg a layer's form has no box レイヤーのフォームにボックスがありません +@msg a layer's form has no dictionary レイヤーのフォームに辞書がありません +@msg a legend draws at most {MAX_CHIPS} swatches 凡例に描画できるスウォッチは最大 {MAX_CHIPS} 個です +@msg a name names one asset: give multiple: false, or one object 名前で指定できるアセットは 1 つです: multiple: false を指定するか、オブジェクトを 1 つにしてください +@msg a page can't be read ページを読み込めません +@msg a page object is used by something else ページオブジェクトが別のものに使用されています +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) ページのサムネールには、1 辺が 1 から {THUMBNAIL_SIZE} までの RGB ピクセルを指定してください (指定値: {w} × {h}) +@msg a pattern layer doesn't end what it starts パターンレイヤーで、開始したものが終了していません +@msg a pattern layer's colors hold something else パターンレイヤーのカラーに別のものが含まれています +@msg a pattern named `{n}` exists `{n}` という名前のパターンがすでにあります +@msg a plug-in with id {_1} is already installed ID が {_1} のプラグインはすでにインストールされています +@msg a preset named `{n}` exists `{n}` という名前のプリセットがすでにあります +@msg a print presets file with over {_1} presets プリントプリセットファイルに {_1} 個を超えるプリセットがあります +@msg a profile is already called {n} {n} という名前のプロファイルがすでにあります +@msg a row can't go into itself 行を自身の中に入れることはできません +@msg a series goes on the left or the right value axis 系列は左または右の数値軸に割り当てます +@msg a start must be an anchor index ≥ 0 or null 開始位置には 0 以上のアンカーのインデックスまたは null を指定してください +@msg a style named `{name}` already exists `{name}` という名前のスタイルがすでにあります +@msg a style named `{to}` already exists `{to}` という名前のスタイルがすでにあります +@msg a swatch needs a colour, gradient or pattern スウォッチにはカラー、グラデーション、パターンのいずれかが必要です +@msg a symbol named `{n}` already exists `{n}` という名前のシンボルがすでにあります +@msg a theme has 1 to {THEME_SIZE} colours テーマのカラーは 1 から {THEME_SIZE} 色までです +@msg a theme is already called {name} (replace: "{name}" overwrites it) {name} という名前のテーマがすでにあります (replace: "{name}" で上書きできます) +@msg a toolbar name has at most {MAX_NAME} characters ツールバー名は {MAX_NAME} 文字以内です +@msg a view named `{_1}` exists `{_1}` という名前の表示がすでにあります +@msg a view named `{name}` exists `{name}` という名前の表示がすでにあります +@msg a workspace keeps at most {MAX} toolbars ワークスペースに保存できるツールバーは最大 {MAX} 個です +@msg a {_1} series can't go in a {_2} graph {_1} の系列は {_2} グラフに入れられません +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF 互換、初期設定) または "7" (旧形式の .ai、バージョン 7) を指定してください +@msg already editing an opacity mask すでに不透明マスクを編集中です +@msg already editing another object's opacity mask すでに別のオブジェクトの不透明マスクを編集中です +@msg already in pattern editing mode すでにパターン編集モードです +@msg an artboard is listed twice 同じアートボードが 2 回指定されています +@msg an empty artboard has no width or height to scale to 空のアートボードには、拡大・縮小の基準となる幅も高さもありません +@msg an image is too large for DXF and was left out 画像が DXF には大きすぎるため、除外しました +@msg an image of {_1} MB, at most {_2} MB 画像が {_1} MB あります (上限は {_2} MB) +@msg an image of {declared_w} × {declared_h} pixels is too large to draw {declared_w} × {declared_h} ピクセルの画像は大きすぎて描画できません +@msg an image of {width} × {height} pixels is too large to draw {width} × {height} ピクセルの画像は大きすぎて描画できません +@msg an index must be a whole number インデックスには整数を指定してください +@msg an inline graphic has no glyphs インライングラフィックにグリフがありません +@msg an invisible text form can't be read 非表示テキストのフォームを読み込めません +@msg an object is missing オブジェクトが見つかりません +@msg an object's header is missing オブジェクトのヘッダーがありません +@msg an overprinting form can't be read オーバープリントのフォームを読み込めません +@msg an unread object has no readable bounds 未読み込みのオブジェクトに、読み取れる境界がありません +@msg an unread object holds a frame of threaded type 未読み込みのオブジェクトに、スレッドテキストのフレームが含まれています +@msg an unread object is missing from its layer 未読み込みのオブジェクトがレイヤーにありません +@msg anchor `smooth` must be true or false アンカーの `smooth` には true または false を指定してください +@msg anchor `{key}` must be [x, y] アンカーの `{key}` には [x, y] を指定してください +@msg anchor `{key}` must be a number アンカーの `{key}` には数値を指定してください +@msg angle and roundness must be finite angle と roundness には有限の値を指定してください +@msg angles must be a list as long as pins (degrees or null) angles には pins と同じ長さのリスト (度数または null) を指定してください +@msg appearance must be flat|center|edge appearance には flat|center|edge のいずれかを指定してください +@msg apply is "fill" or "stroke", not `{other}` apply には "fill" または "stroke" を指定してください (`{other}` は指定できません) +@msg area must be a closed polygon of 3 to 100000 [x, y] points area には 3 から 100000 個の [x, y] の点からなる閉じた多角形を指定してください +@msg area type needs a path that encloses an area エリア内文字には、領域を囲むパスが必要です +@msg area type {_1} is named twice エリア内文字 {_1} が 2 回指定されています +@msg arrowAlign must be extend|tip, got {o} arrowAlign には extend|tip のいずれかを指定してください (指定値: {o}) +@msg arrowScale must be [start, end] arrowScale には [start, end] を指定してください +@msg artboard is a 0-based index artboard には 0 から始まるインデックスを指定してください +@msg artboard must be a 0-based index artboard には 0 から始まるインデックスを指定してください +@msg artboard {i} doesn't print アートボード {i} はプリントされません +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout には {layout?, columns?, spacing?, rightToLeft?} を指定してください +@msg artboardLayout.columns must be 1 or more artboardLayout.columns には 1 以上の値を指定してください +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout には gridByRow、gridByColumn、row、column のいずれかを指定してください +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft には true または false を指定してください +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing には 0–{MAX_SPACING} pt を指定してください +@msg artboards must be a non-empty list artboards には空でないリストを指定してください +@msg artboards must be a number artboards には数値を指定してください +@msg asset `{_1}` has no art to export アセット `{_1}` に書き出せるアートがありません +@msg at most {MAX_KEYWORDS} keywords キーワードは最大 {MAX_KEYWORDS} 個です +@msg at most {MAX_MATCHES} matches are kept 保持される一致は最大 {MAX_MATCHES} 件です +@msg at most {MAX_POINTS} points ポイントは最大 {MAX_POINTS} 個です +@msg at most {MAX_QUEUE} files ファイルは最大 {MAX_QUEUE} 個です +@msg at must be [x, y] at には [x, y] を指定してください +@msg at/rect lie outside the canvas at/rect がキャンバスの外にあります +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute には size|leading|tracking|kerning|baselineShift のいずれかを指定してください +@msg attrs must be an object attrs にはオブジェクトを指定してください +@msg automation path rejected: the folder's name isn't valid text 自動処理のパスを拒否しました: フォルダー名が有効なテキストではありません +@msg automation path rejected: {_1} isn't valid text 自動処理のパスを拒否しました: {_1} が有効なテキストではありません +@msg automation path rejected: {why}: {path} 自動処理のパスを拒否しました: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis には "vertical"、"horizontal"、または度数の角度を指定してください (`{given}` は指定できません) +@msg backgroundContents must be transparent or white backgroundContents には transparent または white を指定してください +@msg bad AES key AES キーが正しくありません +@msg bad `runs`: {e} `runs` が正しくありません: {e} +@msg bad arrowScale arrowScale が正しくありません +@msg bad base64 base64 が正しくありません +@msg bad color {c} カラー {c} が正しくありません +@msg bad colour カラーが正しくありません +@msg bad colour in `colors` `colors` 内のカラーが正しくありません +@msg bad colour {v} カラー {v} が正しくありません +@msg bad colour {v} in groupColors groupColors 内のカラー {v} が正しくありません +@msg bad cross-reference entry 相互参照エントリが正しくありません +@msg bad cross-reference table 相互参照テーブルが正しくありません +@msg bad current colour {v} 現在のカラー {v} が正しくありません +@msg bad dataBase64 dataBase64 が正しくありません +@msg bad edit 編集内容が正しくありません +@msg bad fill {v} 塗り {v} が正しくありません +@msg bad fit: {e} fit が正しくありません: {e} +@msg bad model {other} (keep, new or document) model {other} が正しくありません (keep、new、document のいずれか) +@msg bad new colour {v} 新しいカラー {v} が正しくありません +@msg bad object オブジェクトが正しくありません +@msg bad object number オブジェクト番号が正しくありません +@msg bad offset オフセットが正しくありません +@msg bad path data: {e} パスデータが正しくありません: {e} +@msg bad point {v}: expected [x, y] or [x, y, pressure] 点 {v} が正しくありません: [x, y] または [x, y, pressure] を指定してください +@msg bad reference 参照が正しくありません +@msg bad trailer size トレーラーのサイズが正しくありません +@msg bad {k} colour {k} のカラーが正しくありません +@msg batches cannot nest バッチを入れ子にすることはできません +@msg bind something to a variable first 先に何かを変数にバインドしてください +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed には 0–{MAX_BLEED} pt を指定してください: 数値、[top, bottom, left, right]、{{top, bottom, left, right}} のいずれか +@msg brush must be a name or null brush には名前または null を指定してください +@msg by must be a number by には数値を指定してください +@msg can't add page thumbnails: {why} ページのサムネールを追加できません: {why} +@msg can't add the editing data for other apps: {why} 他のアプリ用の編集データを追加できません: {why} +@msg can't edit the written PDF: {why} 書き出した PDF を編集できません: {why} +@msg can't encrypt the PDF: {why} PDF を暗号化できません: {why} +@msg can't linearise the PDF: {why} PDF をリニアライズできません: {why} +@msg can't open `{_1}`: {_2} `{_1}` を開けません: {_2} +@msg can't open `{_1}`: {e} `{_1}` を開けません: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) `{name}` を開けません: VectorCraft で読み込める形式ではありません (document.formats を参照) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) `{name}` を配置できません: VectorCraft で読み込める形式ではありません (document.formats を参照) +@msg can't read back the written PDF: {e} 書き出した PDF を読み戻せません: {e} +@msg can't read this Word document: {why} この Word 文書を読み込めません: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp .{e} 画像は書き出せません: .png、.jpg、.gif、.webp、.tif、.bmp のいずれかを使用してください +@msg can't write PDF layers, overprint or invisible text: {why} PDF レイヤー、オーバープリント、非表示テキストを書き出せません: {why} +@msg can't write the file's copyright: {why} ファイルの著作権情報を書き出せません: {why} +@msg can't write the file's modification date: {why} ファイルの更新日を書き出せません: {why} +@msg can't write the pages' user units: {why} ページのユーザー単位を書き出せません: {why} +@msg cannot distort a zero-size or non-finite bounding box サイズが 0 または有限でないバウンディングボックスは変形できません +@msg cannot move an object into itself オブジェクトを自身の中に移動することはできません +@msg case must be upper|lower|title|sentence case には upper|lower|title|sentence のいずれかを指定してください +@msg category `{c}`: one of {_1} category `{c}`: {_1} のいずれかを指定してください +@msg center must be [x, y] center には [x, y] を指定してください +@msg choose at least one artboard アートボードを 1 つ以上選択してください +@msg choose at least one status 状態を 1 つ以上選択してください +@msg clip must be true or false clip には true または false を指定してください +@msg clippingMask must be true or false clippingMask には true または false を指定してください +@msg closed must be a boolean closed にはブール値を指定してください +@msg collapsed must be true or false collapsed には true または false を指定してください +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: 「{profile}」という名前の RGB または CMYK プロファイルはありません +@msg colorMode must be rgb or cmyk, not `{m}` colorMode には rgb または cmyk を指定してください (`{m}` は指定できません) +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel には {own} (ドキュメントのモード)、grayscale、bitmap のいずれかを指定してください +@msg colour groups can't go into colour groups カラーグループをカラーグループに入れることはできません +@msg colour, mode and spot apply to solid-colour swatches only colour、mode、spot を適用できるのは単色のスウォッチだけです +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read 列 {column}: `{cell}` はグラフデータファイルの `%` 列で、読み込まれません +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable 列 {column}: `{cell}` が指定している `{name}` は {_1} 変数です +@msg column {column}: `{cell}` names no variable 列 {column}: `{cell}` に該当する変数はありません +@msg column {column}: the variable name is longer than {MAX_NAME} characters 列 {column}: 変数名が {MAX_NAME} 文字を超えています +@msg column: true floats the whole column and takes no onto, above or below column: true は列全体をフローティングにするため、onto、above、below は指定できません +@msg command must be a shape.* command command には shape.* コマンドを指定してください +@msg commands[{i}]: missing `command` commands[{i}]: `command` がありません +@msg composer must be "singleLine" or "everyLine" composer には "singleLine" または "everyLine" を指定してください +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes 複合シェイプに含められるのは、パス、複合パス、グループ、複合シェイプ、文字、ブレンド、エンベロープです +@msg compound {_1} must contain exactly one open path to join 連結するには、複合パス {_1} にオープンパスがちょうど 1 つ含まれている必要があります +@msg connect end must be "start" or "end" connect の end には "start" または "end" を指定してください +@msg connect needs a path other than the one extended connect には、延長したパスとは別のパスが必要です +@msg connect needs id connect には id が必要です +@msg connect path has no open end connect のパスに開いた端点がありません +@msg copy needs path: the file to copy into the document's folder copy には path が必要です: ドキュメントのフォルダーにコピーするファイルを指定してください +@msg copy takes images only: relink placed documents where their files are copy を使用できるのは画像だけです: 配置したドキュメントは、ファイルのある場所で再リンクしてください +@msg copying a file isn't available on the web Web 版ではファイルをコピーできません +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus には unknown、copyrighted、publicDomain のいずれかを指定してください +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl には URL を指定してください (スペースなし、2048 文字以内) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners には [x, y] の点を 4 つ指定してください (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item または file を指定してください +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: アート、仕上がり、裁ち落とし、メディアの各ボックスがあるのは PDF ページだけです。"crop" または "bounding" を使用してください +@msg crop rectangle doesn't overlap the image 切り抜きの長方形が画像と重なっていません +@msg cross-reference and object streams aren't supported 相互参照ストリームとオブジェクトストリームには対応していません +@msg damaged compressed data: {e} 圧縮データが破損しています: {e} +@msg dash lengths must be numbers, got {v} ダッシュの長さには数値を指定してください (指定値: {v}) +@msg dash must be a list of lengths or null, got {v} dash には長さのリストまたは null を指定してください (指定値: {v}) +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) データキー `{k}`: 使用できるのは文字、数字、-、_、. です (name と vc-… は使用できません) +@msg data must be an object of key: value, not {v} data には key: value のオブジェクトを指定してください ({v} は指定できません) +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare データセット `{name}` に、ライブラリで宣言されていない `{var}` の値があります +@msg data set `{name}` holds two values for `{var}` データセット `{name}` に `{var}` の値が 2 つあります +@msg data set `{name}` is declared twice データセット `{name}` が 2 回宣言されています +@msg data takes at most {MAX_DATA} keys at once data に一度に指定できるキーは最大 {MAX_DATA} 個です +@msg dataset `{name}` already exists データセット `{name}` はすでにあります +@msg dataset `{to}` already exists データセット `{to}` はすでにあります +@msg date `{d}`: YYYY-MM-DD date `{d}`: YYYY-MM-DD の形式で指定してください +@msg decoded data is too large デコードしたデータが大きすぎます +@msg define a variable first 先に変数を定義してください +@msg detect must be true or false detect には true または false を指定してください +@msg direct-select anchor points of a path パスのアンカーポイントをダイレクト選択してください +@msg distance must be > 0 distance には 0 より大きい値を指定してください +@msg dx and dy give the arrow's direction dx と dy には矢印の方向を指定してください +@msg dx and dy must be numbers dx と dy には数値を指定してください +@msg dxf fitTo must be [width, height], positive dxf の fitTo には正の値の [width, height] を指定してください +@msg dxf must be an object of DXF import options dxf には DXF 読み込みオプションのオブジェクトを指定してください +@msg dxf scale must be a positive number up to {_1}, not {s} dxf の scale には {_1} 以下の正の数値を指定してください ({s} は指定できません) +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf の unit `{u}`: mm、cm、in、pt などの単位を指定してください +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} 各形式にはオブジェクト {format, scale?, suffix?} を指定してください +@msg each item needs an id 各項目には id が必要です +@msg each match needs `kind` 各一致には `kind` が必要です +@msg each match needs a `name` 各一致には `name` が必要です +@msg each of `fonts` is {family, style?}; 1 to 1000 of them `fonts` の各要素には {family, style?} を指定してください。要素数は 1 から 1000 個です +@msg each of `items` is {kind, item} `items` の各要素には {kind, item} を指定してください +@msg each of `items` is {{id, corners?}} with an object id, not {it} `items` の各要素には、オブジェクト ID を含む {{id, corners?}} を指定してください ({it} は指定できません) +@msg each row needs `from` 各行には `from` が必要です +@msg each stop needs a position 各分岐点には位置が必要です +@msg each subpath needs an anchors array 各サブパスには anchors 配列が必要です +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType には easeIn、easeOut、easeInOut、easeOutIn のいずれかを指定してください +@msg editingData must be true or false editingData には true または false を指定してください +@msg editingData must be true, false or "always" editingData には true、false、"always" のいずれかを指定してください +@msg edits overlap 編集範囲が重なっています +@msg effects of objects whose appearances differ can't be moved together アピアランスが異なるオブジェクトの効果は、まとめて移動できません +@msg empty header in column {_1} 列 {_1} のヘッダーが空です +@msg encoding `{_1}`: utf8 or utf16 encoding `{_1}`: utf8 または utf16 を指定してください +@msg ends must be two of "first" and "last" ends には "first" と "last" から 2 つを指定してください +@msg every numbered name for the result is taken 結果に使用できる番号付きの名前がすべて使用されています +@msg expanded SVG styles exceed the import size limit 展開した SVG スタイルが読み込みサイズの上限を超えています +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes 書き出しプリセットファイルが {MAX_PRESET_FILE_BYTES} バイトを超えています +@msg exportText must be editable or appearance exportText には editable または appearance を指定してください +@msg exportType must be movieClip or graphic, not `{s}` exportType には movieClip または graphic を指定してください (`{s}` は指定できません) +@msg extend needs id extend には id が必要です +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges に指定できるタグは最大 {MAX_FEATURE_CHANGES} 個です +@msg featureChanges must be a list of tags featureChanges にはタグのリストを指定してください +@msg features must be a list of tags features にはタグのリストを指定してください +@msg fidelity must be 0..100 fidelity には 0..100 の値を指定してください +@msg files must be {name, dataBase64} files には {name, dataBase64} を指定してください +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit には none|autoHeight|shrinkText|block のいずれかを指定してください (指定値: {id}) +@msg flattener: {e} 分割・統合: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high、medium、low、または保存済みのプリセットを指定してください (flattener.presets.list を参照) +@msg flipProfile must be along|across, got {o} flipProfile には along|across のいずれかを指定してください (指定値: {o}) +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none、rows、format、item、itemFormat のいずれかを指定してください +@msg folders deeper than {MAX_BATCH_DEPTH} levels フォルダーの階層が {MAX_BATCH_DEPTH} を超えています +@msg folders deeper than {MAX_DEPTH} levels フォルダーの階層が {MAX_DEPTH} を超えています +@msg font family `{family}` is not available フォントファミリー `{family}` は使用できません +@msg font family `{tf}` is not available フォントファミリー `{tf}` は使用できません +@msg format `{f}`: pdf or postscript format `{f}`: pdf または postscript を指定してください +@msg format `{f}`: text or json format `{f}`: text または json を指定してください +@msg format is `{format}` format は `{format}` です +@msg formats must be an array of rows, not {v} formats には行の配列を指定してください ({v} は指定できません) +@msg frequency must be between 1 and 300 lines per inch frequency には 1 から 300 線/インチまでの値を指定してください +@msg from and to must be finite from と to には有限の値を指定してください +@msg from must be [x0, y0, x1, y1] from には [x0, y0, x1, y1] を指定してください +@msg give `before` or `after`, not both `before` または `after` のどちらか一方を指定してください +@msg give `colors`, or `color` and `rule` `colors`、または `color` と `rule` を指定してください +@msg give `event`, such as onclick onclick などの `event` を指定してください +@msg give `file`, the JavaScript file's path or URL `file` に JavaScript ファイルのパスまたは URL を指定してください +@msg give `fill` and/or `stroke` `fill` と `stroke` の一方または両方を指定してください +@msg give `fill`, `stroke` or both `fill`、`stroke`、またはその両方を指定してください +@msg give `folder` `folder` を指定してください +@msg give `fonts`, or open a document `fonts` を指定するか、ドキュメントを開いてください +@msg give `from` or `indices`, not both `from` または `indices` のどちらか一方を指定してください +@msg give `id` (no text is being edited) `id` を指定してください (編集中のテキストがありません) +@msg give `id` and `anchors`, or `items`, not both `id` と `anchors`、または `items` のどちらか一方を指定してください +@msg give `id` or `guide`, not both `id` または `guide` のどちらか一方を指定してください +@msg give `length` or `to` `length` または `to` を指定してください +@msg give `matches`: [{kind, name, target}] `matches` を指定してください: [{kind, name, target}] +@msg give `name` (or edit a pattern) `name` を指定してください (またはパターンを編集してください) +@msg give `name` or `names` `name` または `names` を指定してください +@msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (.wasm ファイル) または `dataBase64` (モジュール) を指定してください +@msg give `path`, `data` or `dataBase64` `path`、`data`、`dataBase64` のいずれかを指定してください +@msg give `path`, `data`, `dataBase64` or `csv` `path`、`data`、`dataBase64`、`csv` のいずれかを指定してください +@msg give `path`, `xml` or `dataBase64` `path`、`xml`、`dataBase64` のいずれかを指定してください +@msg give `script`, the JavaScript the event runs ("" removes the event) `script` に、イベントで実行する JavaScript を指定してください ("" を指定するとイベントを削除します) +@msg give `text` (the reading) or `remove` `text` (読み) または `remove` を指定してください +@msg give `x` and `y` (and a `tolerance` of 0 or more) `x` と `y` (および 0 以上の `tolerance`) を指定してください +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments mode、または r/g/b、c/m/y/k、gray、tint の調整値を指定してください +@msg give a name, or paint the active proxy with a saved preset 名前を指定するか、保存済みのプリセットでアクティブなプロキシをペイントしてください +@msg give a name, or select a stroke with a saved profile 名前を指定するか、保存済みのプロファイルが適用された線を選択してください +@msg give a pattern name or select an object painted with a pattern パターン名を指定するか、パターンでペイントしたオブジェクトを選択してください +@msg give at least one of clip, invert, disabled, linked clip、invert、disabled、linked のうち 1 つ以上を指定してください +@msg give color, none, swatch or gradient color、none、swatch、gradient のいずれかを指定してください +@msg give dataBase64 dataBase64 を指定してください +@msg give either at (a click) or area (a path drawn around the overlaps) at (クリック位置) または area (重なりを囲むように描いたパス) のどちらかを指定してください +@msg give formats or a preset, not both formats またはプリセットのどちらか一方を指定してください +@msg give newName or delete newName または delete を指定してください +@msg give offset or by offset または by を指定してください +@msg give one of `to`, `rect` or `at` `to`、`rect`、`at` のいずれかを指定してください +@msg give one of assets, layers, selection or fullDocument assets、layers、selection、fullDocument のいずれかを指定してください +@msg give only one of onto, above, below, icon and a column place onto、above、below、icon、列の位置のうち 1 つだけを指定してください +@msg give origin: [x, y], or reset: true origin: [x, y] または reset: true を指定してください +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note overprintFill、overprintStroke、showCenter、imageMap、url、note のいずれかを指定してください +@msg give path (a file) or folder path (ファイル) または folder を指定してください +@msg give path, or name and dataBase64 path、または name と dataBase64 を指定してください +@msg give paths or files paths または files を指定してください +@msg give rows, rowHeight, columns or columnWidth rows、rowHeight、columns、columnWidth のいずれかを指定してください +@msg give svg, or dataBase64 of an SVG or SVGZ file svg、または SVG か SVGZ ファイルの dataBase64 を指定してください +@msg give text text を指定してください +@msg give the asset a name アセットに名前を指定してください +@msg give the preset a `name` プリセットに `name` を指定してください +@msg give the preset a name プリセットに名前を指定してください +@msg give the preset a name (1–64 characters) プリセットに名前を指定してください (1–64 文字) +@msg give the settings to change, e.g. {"formats"}]} 変更する設定を指定してください (例: {"formats"}]}) +@msg give the style a name スタイルに名前を指定してください +@msg give two or more style `names` スタイルの `names` を 2 つ以上指定してください +@msg give two or more swatches スウォッチを 2 つ以上指定してください +@msg give x, y, or a handle's length or angle x、y、またはハンドルの length か angle を指定してください +@msg give {key, value} or {values} {key, value} または {values} を指定してください +@msg global must be true or false global には true または false を指定してください +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) この文字のスタイル (オールキャップス、スモールキャップ、数字の体系など) では、{ch} にグリフ {_1} は使用されません +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} グリフ {g} は {_1} {_2} における {ch} の代替字形ではありません +@msg gradient must be "objects" or "mesh" gradient には "objects" または "mesh" を指定してください +@msg grays must be "profile" or "black", not `{g}` grays には "profile" または "black" を指定してください (`{g}` は指定できません) +@msg gridColors must be a preset name or two colours gridColors にはプリセット名または 2 つのカラーを指定してください +@msg gridSize must be small, medium or large gridSize には small、medium、large のいずれかを指定してください +@msg guide {g} is not selected ガイド {g} は選択されていません +@msg handle must be 0..3 handle には 0..3 の値を指定してください +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (初期設定)、"omit"、"visible" のいずれかを指定してください +@msg highlight a layer to merge into 結合先のレイヤーをハイライトしてください +@msg highlight two or more rows in one layer or group 1 つのレイヤーまたはグループ内の行を 2 つ以上ハイライトしてください +@msg highlight two or more rows to merge 結合する行を 2 つ以上ハイライトしてください +@msg icon docks a panel in the icon column: use window.panel.dock icon はパネルをアイコン列にドッキングします: window.panel.dock を使用してください +@msg id must be a dimension id id には寸法の ID を指定してください +@msg image has no bounds 画像に境界がありません +@msg image {_1} is linked already 画像 {_1} はすでにリンクされています +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none、client、server のいずれかを指定してください +@msg index out of range インデックスが範囲外です +@msg instances must be 1..1000 instances には 1..1000 の値を指定してください +@msg invalid `color` `color` が無効です +@msg invalid anchor index {v}: expected a non-negative integer アンカーのインデックス {v} が無効です: 0 以上の整数を指定してください +@msg invalid anchor reference {v}: expected [subpath, anchor] アンカーの参照 {v} が無効です: [subpath, anchor] を指定してください +@msg invalid angle 角度が無効です +@msg invalid brush: {e} ブラシが無効です: {e} +@msg invalid color カラーが無効です +@msg invalid colour {c} カラー {c} が無効です +@msg invalid delta 差分が無効です +@msg invalid point 点が無効です +@msg invalid size サイズが無効です +@msg invalid t t が無効です +@msg invalid tolerance 許容値が無効です +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) アートボードがすべてキャンバスの外にあります (原点から {_1} pt を超えています) +@msg its clipping masks are nested too deeply クリッピングマスクの入れ子が深すぎます +@msg its metadata has no description メタデータに説明がありません +@msg its metadata has no direct length メタデータに直接指定された長さがありません +@msg join two different paths 異なる 2 つのパスを連結してください +@msg justify must be auto|left|center|right|justifyAll justify には auto|left|center|right|justifyAll のいずれかを指定してください +@msg kbps must be a positive number, not {kbps} kbps には正の数値を指定してください ({kbps} は指定できません) +@msg keywords must be a list of words or a comma-separated string keywords には単語のリストまたはカンマ区切りの文字列を指定してください +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords には制御文字を含まない {MAX_KEYWORD} 文字以内の値を指定してください +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind には 1、2、3 のいずれかを指定するか、プリセットの `name` を指定してください (perspective.presets.list を参照) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind には graphic、fillColor、strokeColor、fillGradient、strokeGradient、fillPattern、strokePattern、charStyle、paraStyle のいずれかを指定してください +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout には "on"、"off"、"neutral"、またはブール値を指定してください ({v} は指定できません) +@msg layer `{name}` has no art to export レイヤー `{name}` に書き出せるアートがありません +@msg layer must name a layer or sublayer layer にはレイヤーまたはサブレイヤーを指定してください +@msg layers can't be intertwined: select the objects in them レイヤーを絡み合わせることはできません: レイヤー内のオブジェクトを選択してください +@msg layers go only at the top level or in layers, objects in layers and groups レイヤーを置けるのは最上位またはレイヤー内だけで、オブジェクトを置けるのはレイヤー内とグループ内です +@msg layers must be true or false layers には true または false を指定してください +@msg layout must be gridByRow, gridByColumn, row or column layout には gridByRow、gridByColumn、row、column のいずれかを指定してください +@msg leading must be a number or "auto" leading には数値または "auto" を指定してください +@msg left/right must be non-negative widths left/right には 0 以上の幅を指定してください +@msg level `{t}`: 2 or 3 level `{t}`: 2 または 3 を指定してください +@msg line {line}: more than {MAX_CSV_COLS} columns {line} 行目: 列が {MAX_CSV_COLS} 個を超えています +@msg line {line}: unclosed quote {line} 行目: 引用符が閉じられていません +@msg line {line}: value for `{var}` must match its variable kind {line} 行目: `{var}` の値は変数の種類に合わせてください +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf または crlf を指定してください +@msg loading profiles from disk isn't available in the browser ブラウザーではディスクからプロファイルを読み込めません +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: マージンが {m} pt では用紙にプリント可能な領域が残りません +@msg matrix must be finite and invertible, depth finite matrix には有限で逆行列を持つ値、depth には有限の値を指定してください +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member は order が front、forward、backward、back のいずれかの場合に指定します +@msg missing `artboards` `artboards` がありません +@msg missing `at` [x, y] `at` [x, y] がありません +@msg missing `attrs` `attrs` がありません +@msg missing `color` or `opacity` `color` または `opacity` がありません +@msg missing `colors` `colors` がありません +@msg missing `commands` `commands` がありません +@msg missing `destinationFolder` `destinationFolder` がありません +@msg missing `effect` id `effect` の ID がありません +@msg missing `family` `family` がありません +@msg missing `find` `find` がありません +@msg missing `gid` (a glyph id, or null for the default glyph) `gid` がありません (グリフ ID、または初期設定のグリフにする場合は null) +@msg missing `group` `group` がありません +@msg missing `id` `id` がありません +@msg missing `id`: give one, or select one type object `id` がありません: 指定するか、文字オブジェクトを 1 つ選択してください +@msg missing `index` (a point index, or null to clear) `index` がありません (ポイントのインデックス、または消去する場合は null) +@msg missing `index` (a stop index, or null to clear) `index` がありません (分岐点のインデックス、または消去する場合は null) +@msg missing `index` (an item index or null) `index` がありません (項目のインデックスまたは null) +@msg missing `item` `item` がありません +@msg missing `library` `library` がありません +@msg missing `library` (see graphicStyle.libraries) `library` がありません (graphicStyle.libraries を参照) +@msg missing `library` (see swatch.library.list) `library` がありません (swatch.library.list を参照) +@msg missing `map` (rows [{from, to}] or {current}) `map` がありません (行 [{from, to}] または {current}) +@msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] がありません +@msg missing `name` (see gradient.presets.list) `name` がありません (gradient.presets.list を参照) +@msg missing `offset` (a distance in points) `offset` がありません (ポイント単位の距離) +@msg missing `path` `path` がありません +@msg missing `path` (a path object id) `path` がありません (パスオブジェクトの ID) +@msg missing `rule` `rule` がありません +@msg missing `source` id `source` の ID がありません +@msg missing `start` (the byte offset of a character) `start` がありません (文字のバイトオフセット) +@msg missing `status`: linked, embedded, missing or modified `status` がありません: linked、embedded、missing、modified のいずれかを指定してください +@msg missing `target` id `target` の ID がありません +@msg missing `text` `text` がありません +@msg missing `to` `to` がありません +@msg missing `to` (an index) `to` がありません (インデックス) +@msg missing `to`: a non-negative integer `to` がありません: 0 以上の整数を指定してください +@msg missing `type` `type` がありません +@msg missing `value` `value` がありません +@msg missing `{key}` `{key}` がありません +@msg missing `{k}` `{k}` がありません +@msg missing anchor anchor がありません +@msg missing anchors anchors がありません +@msg missing anchors [[subpath, anchor]…] anchors [[subpath, anchor]…] がありません +@msg missing array `{key}` 配列 `{key}` がありません +@msg missing case case がありません +@msg missing color color がありません +@msg missing commands commands がありません +@msg missing destination folder 出力先フォルダーがありません +@msg missing from from がありません +@msg missing group group がありません +@msg missing id or ids id または ids がありません +@msg missing ids ids がありません +@msg missing indexes indexes がありません +@msg missing matrix [a,b,c,d,e,f] matrix [a,b,c,d,e,f] がありません +@msg missing name name がありません +@msg missing op op がありません +@msg missing or invalid `color` `color` がないか、無効です +@msg missing or unknown `tool` `tool` がないか、不明です +@msg missing point point がありません +@msg missing point [x,y] point [x,y] がありません +@msg missing point `{key}` ([x, y]) 点 `{key}` ([x, y]) がありません +@msg missing points points がありません +@msg missing points [[x,y]…] points [[x,y]…] がありません +@msg missing segment segment がありません +@msg missing stops stops がありません +@msg missing subpaths subpaths がありません +@msg missing target target がありません +@msg missing to to がありません +@msg missing x, y x, y がありません +@msg mode is set, add or toggle, not `{other}` mode には set、add、toggle のいずれかを指定してください (`{other}` は指定できません) +@msg mode must be "cmyk" or "rgb" mode には "cmyk" または "rgb" を指定してください +@msg mode must be area|onPath, got {o} mode には area|onPath のいずれかを指定してください (指定値: {o}) +@msg more than 10000 linked files: package dependencies in smaller parts リンクファイルが 10000 個を超えています: 依存ファイルを小分けにしてパッケージしてください +@msg more than 256 commands コマンドが 256 個を超えています +@msg more than {MAX_BATCH_FILES} data sets データセットが {MAX_BATCH_FILES} 個を超えています +@msg more than {MAX_BATCH_FILES} files ファイルが {MAX_BATCH_FILES} 個を超えています +@msg more than {MAX_CSV_ROWS} data rows データ行が {MAX_CSV_ROWS} 行を超えています +@msg more than {MAX_FILES} images 画像が {MAX_FILES} 個を超えています +@msg more than {MAX_IMPORT} presets プリセットが {MAX_IMPORT} 個を超えています +@msg moved must be [[x,y]…] moved には [[x,y]…] を指定してください +@msg name (a toolbar's name) is required name (ツールバー名) は必須です +@msg name (a toolbar's name) must be a string name (ツールバー名) には文字列を指定してください +@msg name must be a toolbar's name name にはツールバー名を指定してください +@msg names is empty names が空です +@msg names must be an array of symbol names names にはシンボル名の配列を指定してください +@msg need `anchor` or `segment` + `t` `anchor`、または `segment` + `t` が必要です +@msg need at least one anchor アンカーが 1 つ以上必要です +@msg need at least one point 点が 1 つ以上必要です +@msg need at least two points 点が 2 つ以上必要です +@msg needs points 点が必要です +@msg nineSlice must be true or false nineSlice には true または false を指定してください +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides には [left, top, right, bottom] を指定してください。各値は -16..16 で、left + right < 1 かつ top + bottom < 1 にしてください +@msg no PDF preset named `{n}` `{n}` という名前の PDF プリセットはありません +@msg no action set is current 現在のアクションセットがありません +@msg no active data set アクティブなデータセットがありません +@msg no appearance item {index} アピアランス項目 {index} はありません +@msg no artboard {_1} アートボード {_1} はありません +@msg no artboard {artboard} (0-based; the document has {_1}) アートボード {artboard} はありません (0 から始まるインデックス。ドキュメントのアートボードは {_1} 個) +@msg no asset {id} アセット {id} はありません +@msg no asset {id} (see assets.list) アセット {id} はありません (assets.list を参照) +@msg no assets to export 書き出すアセットがありません +@msg no assets yet: collect art for export first (assets.add) アセットがまだありません: 先に書き出すアートを収集してください (assets.add) +@msg no brush named `{n}` `{n}` という名前のブラシはありません +@msg no character at byte {start} バイト {start} の位置に文字はありません +@msg no colour カラーがありません +@msg no colour group `{group}` カラーグループ `{group}` はありません +@msg no colour group `{name}` カラーグループ `{name}` はありません +@msg no data sets データセットがありません +@msg no dataset named `{from}` `{from}` という名前のデータセットはありません +@msg no dataset named `{name}` `{name}` という名前のデータセットはありません +@msg no datasets データセットがありません +@msg no design named `{name}` `{name}` という名前のデザインはありません +@msg no design named `{n}` `{n}` という名前のデザインはありません +@msg no design named `{old}` `{old}` という名前のデザインはありません +@msg no encoder for {_1} yet {_1} のエンコーダーはまだありません +@msg no export preset `{name}` (see document.exportPresets.list) 書き出しプリセット `{name}` はありません (document.exportPresets.list を参照) +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder フォルダーがありません: `path` を指定するか、「環境設定 › プラグイン & 仮想記憶ディスク › 追加プラグインフォルダー」を設定してください +@msg no fonts to look for: they are all available 検索するフォントがありません: すべて使用できます +@msg no formats to export 書き出す形式がありません +@msg no gradient preset `{name}` (see gradient.presets.list) グラデーションプリセット `{name}` はありません (gradient.presets.list を参照) +@msg no graphic style `{n}` in `{_1}` `{_1}` にグラフィックスタイル `{n}` はありません +@msg no graphic style library `{key}` (see graphicStyle.libraries) グラフィックスタイルライブラリ `{key}` はありません (graphicStyle.libraries を参照) +@msg no guide selected ガイドが選択されていません +@msg no guide selected (or give an index) ガイドが選択されていません (またはインデックスを指定してください) +@msg no guide {i} ガイド {i} はありません +@msg no images in the folder フォルダーに画像がありません +@msg no item at index {_1} インデックス {_1} に項目はありません +@msg no item at index {from} インデックス {from} に項目はありません +@msg no item at one of the indices {idx} インデックス {idx} のいずれかに項目がありません +@msg no layer レイヤーがありません +@msg no layer {id} (see layer.list) レイヤー {id} はありません (layer.list を参照) +@msg no layers to export 書き出すレイヤーがありません +@msg no library `{key}` ライブラリ `{key}` はありません +@msg no metafile format メタファイル形式はありません +@msg no object {_1} オブジェクト {_1} はありません +@msg no objects オブジェクトがありません +@msg no parent layer 親レイヤーがありません +@msg no perspective grid preset named `{b}` (see perspective.presets.list) `{b}` という名前の遠近グリッドプリセットはありません (perspective.presets.list を参照) +@msg no perspective grid preset named `{n}` `{n}` という名前の遠近グリッドプリセットはありません +@msg no point selected (give `index`) ポイントが選択されていません (`index` を指定してください) +@msg no preset called `{from}` (see file.newPresets) `{from}` というプリセットはありません (file.newPresets を参照) +@msg no preset called `{name}` (see file.newPresets) `{name}` というプリセットはありません (file.newPresets を参照) +@msg no preset named `{n}` `{n}` という名前のプリセットはありません +@msg no print preset named `{b}` (see print.presets.list) `{b}` という名前のプリントプリセットはありません (print.presets.list を参照) +@msg no print preset named `{name}` (presets: {_1}) `{name}` という名前のプリントプリセットはありません (プリセット: {_1}) +@msg no print preset named `{n}` `{n}` という名前のプリントプリセットはありません +@msg no profile named `{name}` `{name}` という名前のプロファイルはありません +@msg no readable files in the folder フォルダーに読み込めるファイルがありません +@msg no recovery copy `{f}` (see file.recovery.list) 復元用コピー `{f}` はありません (file.recovery.list を参照) +@msg no rows 行がありません +@msg no saved PDF preset named `{name}` `{name}` という名前の保存済み PDF プリセットはありません +@msg no saved export preset `{name}` 保存済みの書き出しプリセット `{name}` はありません +@msg no saved gradient preset called {name} {name} という保存済みのグラデーションプリセットはありません +@msg no saved gradient presets 保存済みのグラデーションプリセットがありません +@msg no saved preset called `{name}` `{name}` という保存済みのプリセットはありません +@msg no saved presets to export (name [Default] in `names` to export it) 書き出す保存済みプリセットがありません ([Default] を書き出すには `names` に指定してください) +@msg no saved presets to export (name built-in ones in `names`) 書き出す保存済みプリセットがありません (組み込みのプリセットは `names` に指定してください) +@msg no saved print preset named `{name}` `{name}` という名前の保存済みプリントプリセットはありません +@msg no saved profile called {name} {name} という保存済みのプロファイルはありません +@msg no saved selection `{name}` 保存した選択範囲 `{name}` はありません +@msg no saved theme called {name} (see colorTheme.list) {name} という保存済みのテーマはありません (colorTheme.list を参照) +@msg no saved views 保存した表示がありません +@msg no series {i} 系列 {i} はありません +@msg no slice {n} in the image (see slice.list) 画像にスライス {n} はありません (slice.list を参照) +@msg no source art 元のアートがありません +@msg no stop {i} (the gradient has {n}) 分岐点 {i} はありません (グラデーションの分岐点は {n} 個) +@msg no style `{name}` スタイル `{name}` はありません +@msg no such assets (see assets.list) 該当するアセットはありません (assets.list を参照) +@msg no such frame anchor, or the frame would reach past the canvas 該当するフレームのアンカーがないか、フレームがキャンバスからはみ出します +@msg no such objects 該当するオブジェクトはありません +@msg no such path anchor, or the path would reach past the canvas 該当するパスのアンカーがないか、パスがキャンバスからはみ出します +@msg no such rows 該当する行はありません +@msg no swatch `{keep}` スウォッチ `{keep}` はありません +@msg no swatch `{n}` スウォッチ `{n}` はありません +@msg no swatch `{n}` (swatches and colour groups move separately) スウォッチ `{n}` はありません (スウォッチとカラーグループは別々に移動します) +@msg no swatch library `{key}` (see swatch.library.list) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照。"document" はドキュメントのスウォッチ) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照。"{DOCUMENT_SWATCHES}" はドキュメントのスウォッチ) +@msg no swatch named `{n}` `{n}` という名前のスウォッチはありません +@msg no swatch or colour group `{n}` スウォッチまたはカラーグループ `{n}` はありません +@msg no swatch or colour group `{n}` in `{_1}` `{_1}` にスウォッチまたはカラーグループ `{n}` はありません +@msg no symbol named `{name}` `{name}` という名前のシンボルはありません +@msg no text objects selected テキストオブジェクトが選択されていません +@msg no toolbar named `{_1}` `{_1}` という名前のツールバーはありません +@msg no toolbar named `{name}` `{name}` という名前のツールバーはありません +@msg no tracing preset `{preset}` (see imageTrace.presets) トレースプリセット `{preset}` はありません (imageTrace.presets を参照) +@msg no user library folder here (save with a path, or without one to get the data) ここにはユーザーライブラリフォルダーがありません (パスを指定して保存するか、パスを指定せずにデータを取得してください) +@msg no variable named `{from}` `{from}` という名前の変数はありません +@msg no variable named `{name}` `{name}` という名前の変数はありません +@msg no variable named `{var}` `{var}` という名前の変数はありません +@msg no variable named `{v}` `{v}` という名前の変数はありません +@msg no view named `{name}` `{name}` という名前の表示はありません +@msg node {_1} is not a graph ノード {_1} はグラフではありません +@msg node {_1} is not an image ノード {_1} は画像ではありません +@msg node {_1} is not type ノード {_1} は文字ではありません +@msg not a PDF presets file (format `{_1}`) PDF プリセットファイルではありません (形式 `{_1}`) +@msg not a PDF presets file: {e} PDF プリセットファイルではありません: {e} +@msg not a Word document: its WordDocument stream doesn't start with a file information block Word 文書ではありません: WordDocument ストリームがファイル情報ブロックで始まっていません +@msg not a Word document: its compound file has no WordDocument stream Word 文書ではありません: 複合ファイルに WordDocument ストリームがありません +@msg not a Word document: its main part is not a WordprocessingML document Word 文書ではありません: メインパートが WordprocessingML 文書ではありません +@msg not a Word document: its package has no main document part Word 文書ではありません: パッケージにメイン文書パートがありません +@msg not a closed path クローズパスではありません +@msg not a flattener presets file (format `{_1}`) 分割・統合プリセットファイルではありません (形式 `{_1}`) +@msg not a flattener presets file: {e} 分割・統合プリセットファイルではありません: {e} +@msg not a layer or group レイヤーでもグループでもありません +@msg not a library file: {e} ライブラリファイルではありません: {e} +@msg not a mesh envelope point メッシュエンベロープのポイントではありません +@msg not a perspective grid presets file (format `{_1}`) 遠近グリッドプリセットファイルではありません (形式 `{_1}`) +@msg not a perspective grid presets file: {e} 遠近グリッドプリセットファイルではありません: {e} +@msg not a print presets file (format `{_1}`) プリントプリセットファイルではありません (形式 `{_1}`) +@msg not a print presets file: {e} プリントプリセットファイルではありません: {e} +@msg not a stored or deflated central record 無圧縮または Deflate 圧縮のセントラルレコードではありません +@msg not a valid SVGZ file: {e} 有効な SVGZ ファイルではありません: {e} +@msg not an RTF file: it doesn't start with {\\rtf RTF ファイルではありません: {\\rtf で始まっていません +@msg not an image VectorCraft reads (see document.formats) VectorCraft で読み込める画像ではありません (document.formats を参照) +@msg not area type (text in a frame) outside perspective 遠近グリッド外のエリア内文字 (フレーム内のテキスト) ではありません +@msg not editing an opacity mask 不透明マスクを編集中ではありません +@msg not type on a path outside perspective 遠近グリッド外のパス上文字ではありません +@msg nothing prints プリントされるものがありません +@msg nothing to bind バインドする対象がありません +@msg nothing to change 変更する内容がありません +@msg nothing to change: give dx, dy, hScale, vScale or rotation 変更する内容がありません: dx、dy、hScale、vScale、rotation のいずれかを指定してください +@msg nothing to crop 切り抜く対象がありません +@msg nothing to export 書き出す対象がありません +@msg nothing to export: the document has no visible art 書き出す対象がありません: ドキュメントに表示されているアートがありません +@msg nothing to mark マークする対象がありません +@msg nothing to print: every artboard picked is blank プリントする対象がありません: 選択したアートボードがすべて空白です +@msg nothing to print: the document has no art that prints プリントする対象がありません: ドキュメントにプリントされるアートがありません +@msg object is not a path オブジェクトがパスではありません +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object オブジェクトの透明は、ハイライトした文字には適用できません。「スポイトオプション」の「透明」をオフにするか、オブジェクト全体を選択してください +@msg object {_1} can't be set as point type オブジェクト {_1} はポイント文字にできません +@msg object {_1} has lost its file's contents: relink it オブジェクト {_1} のファイルの内容が失われています: 再リンクしてください +@msg object {_1} has no path オブジェクト {_1} にパスがありません +@msg object {_1} has no size オブジェクト {_1} にサイズがありません +@msg object {_1} is hidden or locked, or on a locked artboard オブジェクト {_1} は非表示かロックされているか、ロックされたアートボード上にあります +@msg object {_1} is not a placed document オブジェクト {_1} は配置したドキュメントではありません +@msg object {_1} is not an Intertwine group オブジェクト {_1} は織り交ぜグループではありません +@msg object {_1} is not an image オブジェクト {_1} は画像ではありません +@msg object {_1} is not an image or a linked file オブジェクト {_1} は画像でもリンクファイルでもありません +@msg object {_1} is not an image or a placed document オブジェクト {_1} は画像でも配置したドキュメントでもありません +@msg object {_1} is not in this Intertwine group オブジェクト {_1} はこの織り交ぜグループに含まれていません +@msg object {_1} needs a 3D effect at index {index} オブジェクト {_1} にはインデックス {index} に 3D 効果が必要です +@msg object {id} has anchors 0–{_1} for `corners`, not {past} オブジェクト {id} で `corners` に指定できるアンカーは 0–{_1} です ({past} は指定できません) +@msg object {id} has {n} anchors, so no anchor {past} オブジェクト {id} のアンカーは {n} 個のため、アンカー {past} はありません +@msg objects must be none, move or copy objects には none、move、copy のいずれかを指定してください +@msg objects were left out 除外されたオブジェクトがあります +@msg offset out of range オフセットが範囲外です +@msg on must be true or false on には true または false を指定してください +@msg only groups, layers, type and images have a Contents (Characters) row 「内容 (文字)」行があるのは、グループ、レイヤー、文字、画像だけです +@msg only interior mesh lines can be deleted 削除できるのは内側のメッシュラインだけです +@msg only solid colours can be added (not gradients or patterns) 追加できるのは単色だけです (グラデーションとパターンは追加できません) +@msg only solid-colour swatches can be spot colours 特色にできるのは単色のスウォッチだけです +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first この Affinity ファイルは埋め込みプレビューしか読み込めませんでした: 「ファイル › 開く」で警告とともに表示するか、先に Affinity から SVG または PDF を書き出してください +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning この Affinity ファイルは埋め込みプレビューしか読み込めませんでした。「ファイル › 開く」で警告とともに表示できます +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto、above、below、icon、列の位置は window.panel.float と window.panel.dock で指定します +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto、above、below、icon、列の位置を指定する場合、anchor、side、zone、before は指定できません +@msg onto, above, below, icon and a column place take no x, y, width or height onto、above、below、icon、列の位置を指定する場合、x、y、width、height は指定できません +@msg open a document to look for its linked files リンクファイルを検索するには、ドキュメントを開いてください +@msg options must be an object (see file.formatOptions) options にはオブジェクトを指定してください (file.formatOptions を参照) +@msg order must be leftToRight or rightToLeft order には leftToRight または rightToLeft を指定してください +@msg orientation must be portrait or landscape, not `{o}` orientation には portrait または landscape を指定してください (`{o}` は指定できません) +@msg outlineText {v}: true or false outlineText {v}: true または false を指定してください +@msg output intent profile: {e} 出力インテントのプロファイル: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: 「{name}」という名前の RGB または CMYK プロファイルはありません +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} ファイルは CMYK ですが、「{name}」は RGB プロファイルです +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: プリントするように設定されたインキがありません +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: 「{name}」という名前のプロファイルはありません。{std} の出力インテントにはプロファイルを埋め込みます +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: 「{name}」という名前のプロファイルはありません: {std} の出力インテントには、プロファイルを埋め込むか、登録済みの印刷条件 (outputConditionId と registry) を指定します +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: {std} ファイルは CMYK でプリントされますが、「{name}」は RGB プロファイルです +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: 「{name}」はグレープロファイルのため、{std} の出力インテントにはできません +@msg overlap: {o} pt is half a tile or more overlap: {o} pt はタイルの半分以上です +@msg overlapping Shaper paths must be in the same layer or group 重なり合う Shaper パスは、同じレイヤーまたはグループ内にある必要があります +@msg overlapping edits 編集範囲が重なっています +@msg page art conflicts with image data for {key} ページのアートが {key} の画像データと一致しません +@msg page art conflicts with pattern {name} ページのアートがパターン {name} と一致しません +@msg page art conflicts with swatch {name} ページのアートがスウォッチ {name} と一致しません +@msg page art is missing image data for {key} ページのアートに {key} の画像データがありません +@msg page art is missing pattern {name} ページのアートにパターン {name} がありません +@msg page art is missing swatch {name} ページのアートにスウォッチ {name} がありません +@msg page must be a whole number from 1 page には 1 以上の整数を指定してください +@msg page thumbnail: {e} ページのサムネール: {e} +@msg page {page} of the PDF has no objects PDF の {page} ページにオブジェクトがありません +@msg page {page}: `{file}` has {n} {artboards} {page} ページ: `{file}` には {n} {artboards} あります +@msg page {page}: `{name}` has {n} {pages} {page} ページ: `{name}` には {n} {pages} あります +@msg pages must be a range such as "2-3" or a page number, not {v} pages には "2-3" のような範囲またはページ番号を指定してください ({v} は指定できません) +@msg pages: {e} pages: {e} +@msg panel position must be finite パネルの位置には有限の値を指定してください +@msg parameters must be an object parameters にはオブジェクトを指定してください +@msg params must be an object params にはオブジェクトを指定してください +@msg passwords can have up to {MAX_PASSWORD} bytes パスワードは {MAX_PASSWORD} バイト以内です +@msg path has no open end パスに開いた端点がありません +@msg paths must be strings paths には文字列を指定してください +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false の場合、編集可能なアートワークを保持するためにすべてのアートボードが必要です +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single または multiple を指定してください +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom には "artwork" または "swatches" を指定してください ({v} は指定できません) +@msg pins and moved must be non-empty lists of the same length pins と moved には、同じ長さの空でないリストを指定してください +@msg pins must be [[x,y]…] pins には [[x,y]…] を指定してください +@msg pins must be finite pins には有限の値を指定してください +@msg place is above, below or inside, not `{other}` place には above、below、inside のいずれかを指定してください (`{other}` は指定できません) +@msg plane must be left|right|ground plane には left|right|ground のいずれかを指定してください +@msg plane must be left|right|ground|none plane には left|right|ground|none のいずれかを指定してください +@msg point is outside the mesh 点がメッシュの外にあります +@msg points must be [t, left, right] with non-negative widths points には、幅が 0 以上の [t, left, right] を指定してください +@msg points must be a non-empty [[x,y,pressure?]…] list points には空でない [[x,y,pressure?]…] のリストを指定してください +@msg points must be a non-empty list of finite [x,y] points には有限の [x,y] からなる空でないリストを指定してください +@msg points must be an array or null points には配列または null を指定してください +@msg points must be finite points には有限の値を指定してください +@msg points must be finite [x,y] pairs points には有限の [x,y] の組を指定してください +@msg points must contain 1–4096 coordinates points には 1–4096 個の座標を指定してください +@msg position must be normal, superscript or subscript position には normal、superscript、subscript のいずれかを指定してください +@msg position must be topLeft, topRight, bottomLeft or bottomRight position には topLeft、topRight、bottomLeft、bottomRight のいずれかを指定してください +@msg positions must be between 0 and 100000 pt positions には 0 から 100000 pt までの値を指定してください +@msg ppi must be a positive number, not {ppi} ppi には正の数値を指定してください ({ppi} は指定できません) +@msg ppi must be between 1 and 2400 ppi には 1 から 2400 までの値を指定してください +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` は transforms|bounds|fileDimensions|fit|fill のいずれでもありません +@msg preserveShape must be clippingMask|transparency preserveShape には clippingMask|transparency のいずれかを指定してください +@msg preset `{id}`: mobile or density preset `{id}`: mobile または density を指定してください +@msg preset must be standard, diffuse, topLeft or right preset には standard、diffuse、topLeft、right のいずれかを指定してください +@msg preset must be the name of a print preset preset にはプリントプリセットの名前を指定してください +@msg previewMode must be default, pixel or overprint previewMode には default、pixel、overprint のいずれかを指定してください +@msg print settings: {e} プリント設定: {e} +@msg procedures nest too deeply プロシージャの入れ子が深すぎます +@msg procedures nested too deeply プロシージャの入れ子が深すぎます +@msg quotes must be {double?, single?} quotes には {double?, single?} を指定してください +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} には開き引用符と閉じ引用符を指定してください (例: "“”") +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi には 1–2400 を指定してください (72 がスクリーン、150 が標準、300 が高解像度) +@msg rating must be a whole number from 0 to {MAX_RATING} rating には 0 から {MAX_RATING} までの整数を指定してください +@msg rect and {k} contradict each other: give the area or the artboards rect と {k} が矛盾しています: 領域またはアートボードのどちらかを指定してください +@msg rect applies to raster formats, not {_1} rect はラスター形式に適用されます ({_1} には適用されません) +@msg rect is [x, y, width, height] in points rect には [x, y, width, height] をポイント単位で指定してください +@msg rect must be [x, y, width, height] rect には [x, y, width, height] を指定してください +@msg rect must be [x, y, width, height] with a positive width and height rect には、幅と高さが正の [x, y, width, height] を指定してください +@msg rect needs finite numbers and a positive width and height rect には有限の数値と、正の幅と高さが必要です +@msg redraw needs id redraw には id が必要です +@msg redraw within must be a finite distance of 0 or more redraw の within には 0 以上の有限の距離を指定してください +@msg registration must be 0..8 registration には 0..8 の値を指定してください +@msg release a layer or a group レイヤーまたはグループを解除してください +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace では置き換えられるオブジェクトの位置と変形が保持されます: template、at、rect は指定しないでください +@msg replace: select the one object to replace replace: 置き換えるオブジェクトを 1 つ選択してください +@msg resolution must be a number or screen|medium|high resolution には数値、または screen|medium|high のいずれかを指定してください +@msg resolution must be between 1 and 2400 ppi resolution には 1 から 2400 ppi までの値を指定してください +@msg rotation must be a number of degrees rotation には度数の数値を指定してください +@msg rotation must be finite rotation には有限の値を指定してください +@msg rows and cols must be 1..50 rows と cols には 1..50 の値を指定してください +@msg rows and columns must be 1..500 rows と columns には 1..500 の値を指定してください +@msg rows of objects whose appearances differ can't be reordered together アピアランスが異なるオブジェクトの行は、まとめて並べ替えられません +@msg save the document first: Package collects a saved document 先にドキュメントを保存してください: パッケージは保存済みのドキュメントを収集します +@msg save the document first: copy puts the file in its folder 先にドキュメントを保存してください: copy はファイルをドキュメントのフォルダーに置きます +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: 2x のような倍率、幅 (100w)、高さ (100h)、解像度 (72ppi) のいずれかを指定してください +@msg scale must be a number above 0, at most 64 scale には 0 より大きく 64 以下の数値を指定してください +@msg scale must be non-zero scale には 0 以外の値を指定してください +@msg scope must be selection or all (got {other}) scope には selection または all を指定してください (指定値: {other}) +@msg scribble is too long 落書きが長すぎます +@msg segment does not exist セグメントが存在しません +@msg select a Shaper group Shaper グループを選択してください +@msg select a blend to set easing or color shift イージングまたはカラーシフトを設定するブレンドを選択してください +@msg select a compound shape 複合シェイプを選択してください +@msg select a graph グラフを選択してください +@msg select a group or layer with visible, unlocked members 表示されていてロックされていないメンバーを含むグループまたはレイヤーを選択してください +@msg select a layer レイヤーを選択してください +@msg select a text object テキストオブジェクトを選択してください +@msg select an image (or an Image Trace group) 画像 (または画像トレースグループ) を選択してください +@msg select an image, or pass id 画像を選択するか、id を指定してください +@msg select an object (or pass `id`) オブジェクトを選択してください (または `id` を指定してください) +@msg select an object with a freeform gradient フリーフォームグラデーションのあるオブジェクトを選択してください +@msg select an object with an opacity mask 不透明マスクのあるオブジェクトを選択してください +@msg select area type エリア内文字を選択してください +@msg select area type (text in a frame) エリア内文字 (フレーム内のテキスト) を選択してください +@msg select area type and closed paths (at least one area type object) エリア内文字とクローズパスを選択してください (エリア内文字オブジェクトが 1 つ以上必要です) +@msg select area type whose first line can fit its frame 1 行目がフレームに収まるエリア内文字を選択してください +@msg select artwork to add its colours カラーを追加するアートワークを選択してください +@msg select artwork to make a group of its colours カラーのグループを作成するアートワークを選択してください +@msg select artwork to recolour, or give a colour `group` 再配色するアートワークを選択するか、カラーの `group` を指定してください +@msg select exactly two path ends to join 連結するパスの端点をちょうど 2 つ選択してください +@msg select members of a compound shape 複合シェイプのメンバーを選択してください +@msg select objects or give ids オブジェクトを選択するか、ids を指定してください +@msg select objects to align to the guide ガイドに整列するオブジェクトを選択してください +@msg select one Intertwine group 織り交ぜグループを 1 つ選択してください +@msg select one image or pass id 画像を 1 つ選択するか、id を指定してください +@msg select paths パスを選択してください +@msg select paths or compound paths パスまたは複合パスを選択してください +@msg select rows or objects to collect 収集する行またはオブジェクトを選択してください +@msg select something to export 書き出す対象を選択してください +@msg select something to export (or turn off selectedOnly) 書き出す対象を選択してください (または selectedOnly をオフにしてください) +@msg select text to redefine the style from スタイルの再定義に使用するテキストを選択してください +@msg select the art and, on top of it, the mask object アートと、その前面にあるマスクオブジェクトを選択してください +@msg select the art for the design デザインにするアートを選択してください +@msg select the art for the pattern tile パターンタイルにするアートを選択してください +@msg select the art to add first 先に追加するアートを選択してください +@msg select the art to collect (or give its ids) 収集するアートを選択してください (またはその ids を指定してください) +@msg select the content and a path on top 内容と、その前面にあるパスを選択してください +@msg select the images to relink, or pass ids 再リンクする画像を選択するか、ids を指定してください +@msg select the images, or pass ids 画像を選択するか、ids を指定してください +@msg select the linked images or placed documents, or pass ids リンク画像または配置したドキュメントを選択するか、ids を指定してください +@msg select the object to copy from コピー元のオブジェクトを選択してください +@msg select the objects (or give their ids) オブジェクトを選択してください (またはその ids を指定してください) +@msg select the objects to attach to the path パスに配置するオブジェクトを選択してください +@msg select the objects to bind バインドするオブジェクトを選択してください +@msg select two or more objects to intertwine 織り交ぜるオブジェクトを 2 つ以上選択してください +@msg select two or more slices to combine 結合するスライスを 2 つ以上選択してください +@msg select type on a path パス上文字を選択してください +@msg selection `{m}`: one or each selection `{m}`: one または each を指定してください +@msg settings is an object of document.exportForScreens params settings には document.exportForScreens のパラメーターのオブジェクトを指定してください +@msg settings must be an object settings にはオブジェクトを指定してください +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all、missing、modified、embedded のいずれかを指定してください +@msg show must be true or false show には true または false を指定してください +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size には "small"、"medium"、"large"、または 0 より大きく {_1} 以下のポイント数を指定してください +@msg size must be positive size には正の値を指定してください +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode には "fixed" または "pressure" を指定してください (`{other}` は指定できません) +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode には "fixed" または "pressure" を指定してください ({other} は指定できません) +@msg sizeVariation must be 0 or more sizeVariation には 0 以上の値を指定してください +@msg smallCaps must be true or false smallCaps には true または false を指定してください +@msg sort `{x}`: name, kind or status sort `{x}`: name、kind、status のいずれかを指定してください +@msg source paths are too large for Shaper 元パスが Shaper には大きすぎます +@msg spot, mode and group apply to solid colours only spot、mode、group を適用できるのは単色だけです +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked、embedded、missing、modified のいずれかを指定してください +@msg steps must be 1..1000 steps には 1..1000 の値を指定してください +@msg steps must be 1..{MAX_STEPS} steps には 1..{MAX_STEPS} の値を指定してください +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) 分岐点 {i}: `plates` には 0 から 1 までの数値を 4 つ指定してください (C, M, Y, K) +@msg stopped before the archive was finished アーカイブが完成する前に停止しました +@msg strokeMode must be within|along|across, got {m} strokeMode には within|along|across のいずれかを指定してください (指定値: {m}) +@msg strokeOptions must be an object of stroke.set options strokeOptions には stroke.set のオプションのオブジェクトを指定してください +@msg strokeWidth must be a positive number of pixels strokeWidth には正のピクセル数を指定してください +@msg subpath {si} is not an open subpath サブパス {si} はオープンなサブパスではありません +@msg subpaths required when joining the same path 同じパスを連結する場合は subpaths が必要です +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict には "merge" または "add" を指定してください ({v} は指定できません) +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text には {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} を指定してください +@msg text transform is collapsed or unrepresentable テキストの変形がつぶれているか、表現できません +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces には 2 から 100 までの整数を指定してください +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: "{_1}" または "{_2}" を使用してください +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: 画像ファイルの {path} または {name, dataBase64}、あるいは effect.list が返す形式のテクスチャを指定してください +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices スライスが {MAX_PIECES} 個を超えます +@msg that object is not in the overlap そのオブジェクトは重なりの中にありません +@msg the Affinity document has no page with a size Affinity ドキュメントにサイズのあるページがありません +@msg the CMYK image couldn't be written CMYK 画像を書き出せませんでした +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected PDF のセキュリティ設定はパスワードなしで保存されています: openPassword または permissionsPassword をもう一度指定するか、保護なしで書き出すには passwordRequired のない security を指定してください +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) RTF ファイルの段落または書式の変更が多すぎて、文字として配置できません (最大 {MAX_PARAGRAPHS} 段落、{MAX_RUNS} ラン) +@msg the RTF file is too large to place as type (at most {_1} MB) RTF ファイルが大きすぎて、文字として配置できません (最大 {_1} MB) +@msg the SVG expands to more than {0} nested SVG images SVG を展開すると、入れ子の SVG 画像が {0} 個を超えます +@msg the SVG expands to more than {0} objects SVG を展開すると、オブジェクトが {0} 個を超えます +@msg the SVG has no drawable objects SVG に描画できるオブジェクトがありません +@msg the SVGZ file unpacks to more than {_1} MB SVGZ ファイルを展開すると {_1} MB を超えます +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place Word 文書のプロパティが {MAX_PAGES} ページを超えており、多すぎて配置できません +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place Word 文書の文字または段落プロパティのランが {MAX_RUNS} 個を超えており、多すぎて配置できません +@msg the Word document has more than {_1} MB of formatting properties, too many to place Word 文書の書式プロパティが {_1} MB を超えており、多すぎて配置できません +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) Word 文書のテキストまたは書式が多すぎて、文字として配置できません (最大 {MAX_PARAGRAPHS} 段落、{MAX_RUNS} ラン、テキスト {_1} MB) +@msg the Word document has too much text to place as type (at most {_1} MB) Word 文書のテキストが多すぎて、文字として配置できません (最大 {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it Word 文書が暗号化されているか、パスワードで保護されています。配置するには、パスワードなしで保存してください +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) Word 文書が大きすぎて配置できません ({name} ストリームが {_1} MB を超えています) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off Word 文書は高速保存で保存されていますが、VectorCraft はこの形式を読み込めません。高速保存をオフにして保存し直してください +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document Word 文書は Word 6.0 または Word 95 で保存されていますが、VectorCraft はこの形式を読み込めません。Word 97-2003 文書として保存してください +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place Word 文書のテキストが {MAX_PIECES} 個を超えるピースに分かれており、多すぎて配置できません +@msg the action set ids are not a list アクションセットの ID がリストではありません +@msg the active paint is not a gradient アクティブなペイントがグラデーションではありません +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size アートボードがキャンバスに収まりません: アートボードの数を減らすか、間隔またはサイズを小さくしてください +@msg the character's size can't be measured 文字のサイズを計測できません +@msg the compound shape is too complex to expand 複合シェイプが複雑すぎて拡張できません +@msg the container of the listed objects is gone 指定したオブジェクトを含むコンテナがなくなりました +@msg the copy `{user_id}` can't be read as a library コピー `{user_id}` をライブラリとして読み込めません +@msg the copy would leave the canvas コピーがキャンバスからはみ出します +@msg the cut cannot be made on this art: {e} このアートはカットできません: {e} +@msg the data isn't an EMF or WMF picture データが EMF または WMF の画像ではありません +@msg the data isn't an EPS or PostScript picture データが EPS または PostScript の画像ではありません +@msg the deferred paste is no longer available 遅延ペーストは使用できなくなりました +@msg the design is too big to draw at every data point of this graph デザインが大きすぎて、このグラフのすべてのデータポイントには描画できません +@msg the design is too big to draw in every column of this graph デザインが大きすぎて、このグラフのすべての棒には描画できません +@msg the dialog it was opened over is no longer open 元のダイアログはすでに閉じられています +@msg the dictionary stack overflowed 辞書スタックがオーバーフローしました +@msg the editing data's hash has no place 編集データのハッシュを書き込む場所がありません +@msg the entities the document declares expand to too much text ドキュメントで宣言されたエンティティを展開すると、テキストが多くなりすぎます +@msg the envelope's content has no bounds エンベロープの内容に境界がありません +@msg the envelope's mesh is malformed エンベロープのメッシュの形式が正しくありません +@msg the file holds no `variableSets` element ファイルに `variableSets` 要素がありません +@msg the file is larger than {_1} MB ファイルが {_1} MB を超えています +@msg the file is too large ファイルが大きすぎます +@msg the file was updated or encrypted already ファイルはすでに更新または暗号化されています +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) 行の先頭の文字には、カーニングの対象となる前の文字がありません: 上下 (dy) にのみ移動します +@msg the font {_1} is not available フォント {_1} は使用できません +@msg the format has no file extension この形式にはファイル拡張子がありません +@msg the graphic can't be read: {e} グラフィックを読み込めません: {e} +@msg the graphic is empty グラフィックが空です +@msg the graphic's data is damaged グラフィックのデータが破損しています +@msg the graphic's document has no artboard グラフィックのドキュメントにアートボードがありません +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them `{name}` のヘッダー `{cell}` は、読み戻すと変数 `{other}` として扱われます: どちらかの名前を変更してください +@msg the image `{path}` isn't followed: {r} 画像 `{path}` は参照しません: {r} +@msg the image has no embedded pixels 画像に埋め込みピクセルがありません +@msg the image is empty 画像が空です +@msg the image is fully transparent 画像が完全に透明です +@msg the image is not on an artboard; pass rect 画像がアートボード上にありません。rect を指定してください +@msg the image's pixels are missing 画像のピクセルがありません +@msg the image's pixels aren't in the document 画像のピクセルがドキュメント内にありません +@msg the image's pixels can't be read: {e} 画像のピクセルを読み込めません: {e} +@msg the image's transparency doesn't fit it 画像の透明情報が画像と合っていません +@msg the job would print more than {MAX_PAGES} pages このジョブでは {MAX_PAGES} ページを超えてプリントされます +@msg the job would print more than {_1} pages このジョブでは {_1} ページを超えてプリントされます +@msg the layers hold no art to mask レイヤーにマスクするアートがありません +@msg the layout moved レイアウトが移動しました +@msg the library has a group `{name}` already ライブラリにはすでにグループ `{name}` があります +@msg the library has no group `{name}` ライブラリにグループ `{name}` はありません +@msg the library has no item `{item}` ライブラリに項目 `{item}` はありません +@msg the library has no item `{other}` ライブラリに項目 `{other}` はありません +@msg the library holds {MAX_GROUPS} groups already ライブラリにはすでに {MAX_GROUPS} 個のグループがあります +@msg the library holds {MAX_ITEMS} items of this kind already ライブラリにはこの種類の項目がすでに {MAX_ITEMS} 個あります +@msg the name is empty 名前が空です +@msg the object has no fill, stroke or effect オブジェクトに塗り、線、効果のいずれもありません +@msg the object has no graphic style (pass `name`) オブジェクトにグラフィックスタイルがありません (`name` を指定してください) +@msg the objects don't match the plan オブジェクトが計画と一致しません +@msg the open and permissions passwords must differ 文書を開くパスワードと権限パスワードには異なるものを指定してください +@msg the operand stack overflowed オペランドスタックがオーバーフローしました +@msg the page has no area ページに面積がありません +@msg the page's type in an unread object's box isn't in its page art 未読み込みオブジェクトのボックス内にあるページの文字が、ページのアートにありません +@msg the paint behind the proxy is not a gradient プロキシのペイントがグラデーションではありません +@msg the parent must be a layer or sublayer 親にはレイヤーまたはサブレイヤーを指定してください +@msg the path can't be extended パスを延長できません +@msg the path can't be inside one of the objects to attach パスを、配置するオブジェクトの中に置くことはできません +@msg the path has no segments to measure パスに計測できるセグメントがありません +@msg the path is empty パスが空です +@msg the path is too short to shorten that much パスが短すぎて、その長さだけ短くできません +@msg the picture has no objects 画像にオブジェクトがありません +@msg the program runs too long プログラムの実行時間が長すぎます +@msg the program uses too much memory プログラムのメモリ使用量が多すぎます +@msg the rectangle must have a width and a height 長方形には幅と高さが必要です +@msg the rectangle needs a size and must lie on the canvas 長方形にはサイズが必要で、キャンバス上にある必要があります +@msg the selected art has no width 選択したアートに幅がありません +@msg the selection draws nothing 選択範囲は何も描画しません +@msg the selection has no area 選択範囲に面積がありません +@msg the selection has no art to export 選択範囲に書き出せるアートがありません +@msg the selection has no bounds 選択範囲に境界がありません +@msg the selection has no graphic style 選択範囲にグラフィックスタイルがありません +@msg the slice would leave the canvas スライスがキャンバスからはみ出します +@msg the spot colour couldn't be added 特色を追加できませんでした +@msg the subpath has no ends to extend (it is closed or a single point) サブパスに延長できる端点がありません (閉じているか、点が 1 つだけです) +@msg the symbols are nested too deeply to expand their instances シンボルの入れ子が深すぎて、インスタンスを拡張できません +@msg the text file is too large to place as type (at most {_1} MB) テキストファイルが大きすぎて、文字として配置できません (最大 {_1} MB) +@msg the text is empty テキストが空です +@msg the text is over {_1} MB テキストが {_1} MB を超えています +@msg the top object must be a path 最前面のオブジェクトはパスである必要があります +@msg the trace was cancelled トレースがキャンセルされました +@msg the trailer has no size トレーラーにサイズがありません +@msg the type object is flattened to nothing 文字オブジェクトを分割・統合すると何も残りません +@msg the value of axis `{tag}` must be a number or null 軸 `{tag}` の値には数値または null を指定してください +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets 変数ライブラリで宣言されているデータセットが {MAX_LIBRARY_DATASETS} 個を超えています +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities 変数ライブラリで宣言されているエンティティが {MAX_LIBRARY_ENTITIES} 個を超えています +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables 変数ライブラリで宣言されている変数が {MAX_LIBRARY_VARIABLES} 個を超えています +@msg the variable library declares no variables 変数ライブラリで変数が宣言されていません +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes 変数ライブラリが {MAX_LIBRARY_BYTES} バイトを超えています +@msg the variable library is not UTF-8 変数ライブラリが UTF-8 ではありません +@msg the variable library is not well-formed XML: {e} 変数ライブラリが整形式の XML ではありません: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep 変数ライブラリの要素の入れ子が {MAX_LIBRARY_NESTING} 階層を超えています +@msg the variable library's document type declaration holds something other than entities of plain text 変数ライブラリの文書型宣言に、プレーンテキストのエンティティ以外のものが含まれています +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes 変数ライブラリのエンティティを展開すると {MAX_LIBRARY_BYTES} バイトを超えます +@msg the window has no dock ウィンドウにドックがありません +@msg the written PDF has no catalog 書き出した PDF にカタログがありません +@msg the written PDF has no cross-reference table 書き出した PDF に相互参照テーブルがありません +@msg the written PDF's metadata can't be edited 書き出した PDF のメタデータを編集できません +@msg there are no colour swatches to draw 描画するカラースウォッチがありません +@msg there is already a design named `{name}` `{name}` という名前のデザインがすでにあります +@msg there is already a design named `{to}` `{to}` という名前のデザインがすでにあります +@msg there is already a toolbar named `{name}` `{name}` という名前のツールバーがすでにあります +@msg there is already a toolbar named `{to}` `{to}` という名前のツールバーがすでにあります +@msg there is no library: create one first ライブラリがありません: 先に作成してください +@msg these paths are too degenerate for Shaper これらのパスは縮退しているため、Shaper では使用できません +@msg thumbnail: no page {v} (the {what} has {count}) thumbnail: ページ {v} はありません ({what} のページ数は {count}) +@msg tileRange of {what}: {e} {what} の tileRange: {e} +@msg title can't be empty title を空にすることはできません +@msg to (the new name) is required to (新しい名前) は必須です +@msg to must be fill or stroke to には fill または stroke を指定してください +@msg to must be smooth or corner to には smooth または corner を指定してください +@msg too large for a zip archive: package to a folder zip アーカイブには大きすぎます: フォルダーにパッケージしてください +@msg too many Shaper edits Shaper の編集が多すぎます +@msg too many `points` (at most 100000) `points` が多すぎます (最大 100000) +@msg too many characters: at most {MAX_CHARS} 文字数が多すぎます: 最大 {MAX_CHARS} 文字 +@msg too many dots for this size: lower the frequency このサイズにはドットが多すぎます: 線数を下げてください +@msg too many filters over one file 1 つのファイルにかかるフィルターが多すぎます +@msg too many overlapping paths for Shaper 重なり合うパスが Shaper には多すぎます +@msg too many saved clips 保存されたクリップが多すぎます +@msg too many saved graphics states 保存されたグラフィックス状態が多すぎます +@msg toolbar (a toolbar's name) must be a string toolbar (ツールバー名) には文字列を指定してください +@msg toolbar name can't be empty ツールバー名を空にすることはできません +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (初期設定) または "omit" を指定してください +@msg two saved selections would be named `{_1}` 保存した選択範囲のうち 2 つが `{_1}` という名前になります +@msg type dimension cannot be represented with this text transform このテキストの変形では文字の寸法を表現できません +@msg type dimension must be finite 文字の寸法には有限の値を指定してください +@msg unexpected resources 予期しないリソースがあります +@msg unknown CMYK profile `{name}` `{name}` は不明な CMYK プロファイルです +@msg unknown File Info field `{k}` `{k}` は不明なファイル情報フィールドです +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges 内の `{t}` は不明な OpenType 機能です (`features` で使用できるタグを指定してください) +@msg unknown PDF preset `{name}` (presets: {_1}) `{name}` は不明な PDF プリセットです (プリセット: {_1}) +@msg unknown Warp style 不明なワープスタイルです +@msg unknown alignToPath `{a}` `{a}` は不明な alignToPath の指定です +@msg unknown arrowhead {v} {v} は不明な矢印です +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) `{k}` は不明な artboardLayout のキーです (layout、columns、spacing、rightToLeft のいずれか) +@msg unknown attribute `{attribute}` `{attribute}` は不明な属性です +@msg unknown attribute `{k}` `{k}` は不明な属性です +@msg unknown axis `{other}` (vertical|horizontal|free) `{other}` は不明な軸の指定です (vertical|horizontal|free) +@msg unknown blend mode `{b}` `{b}` は不明な描画モードです +@msg unknown blend mode {b} {b} は不明な描画モードです +@msg unknown brush type `{o}` `{o}` は不明なブラシの種類です +@msg unknown category `{c}` `{c}` は不明なカテゴリです +@msg unknown category `{c}` ({_1}) `{c}` は不明なカテゴリです ({_1}) +@msg unknown character `{c}` `{c}` は不明な文字です +@msg unknown character style number field 文字スタイルの数値フィールドが不明です +@msg unknown effect `{e}` `{e}` は不明な効果です +@msg unknown end {e} {e} は不明な end の指定です +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) `{name}` は不明な flattenerPreset です (High Resolution、Medium Resolution、Low Resolution、または保存済みのプリセット: flattener.presets.list を参照) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) `{f}` は不明な形式です (vcswatches、gpl、ase、css のいずれか) +@msg unknown from {f} {f} は不明な from の指定です +@msg unknown graph type `{t}` `{t}` は不明なグラフの種類です +@msg unknown grid colours `{name}` (one of {_1}) `{name}` は不明なグリッドカラーです ({_1} のいずれか) +@msg unknown gridType `{t}` `{t}` は不明な gridType です +@msg unknown highlight `{v}` `{v}` は不明なハイライトの指定です +@msg unknown intent `{i}` `{i}` は不明なインテントです +@msg unknown issue kind {id} {id} は不明な問題の種類です +@msg unknown issue kind {v} {v} は不明な問題の種類です +@msg unknown knockout state {k} {k} は不明な抜きの状態です +@msg unknown language `{language}` (one of {_1}) `{language}` は不明な言語です ({_1} のいずれか) +@msg unknown language `{l}` (one of {_1}) `{l}` は不明な言語です ({_1} のいずれか) +@msg unknown method `{m}` ({_1}) `{m}` は不明な方法です ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) `{other}` は不明なモードです (gray、rgb、hsb、lab、cmyk、web のいずれか) +@msg unknown mode `{other}` (mono|cmyk) `{other}` は不明なモードです (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) `{other}` は不明なモードです (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) `{other}` は不明なモードです (set、add、subtract のいずれか) +@msg unknown mode `{o}` `{o}` は不明なモードです +@msg unknown mode {m} {m} は不明なモードです +@msg unknown model `{other}` `{other}` は不明な model です +@msg unknown model `{other}` (keep, new or document) `{other}` は不明な model です (keep、new、document のいずれか) +@msg unknown op {o} {o} は不明な op です +@msg unknown order `{o}`: toggle, front, forward, backward or back `{o}` は不明な order です: toggle、front、forward、backward、back のいずれかを指定してください +@msg unknown overprints `{v}` (preserve, simulate or discard) `{v}` は不明な overprints の指定です (preserve、simulate、discard のいずれか) +@msg unknown panel 不明なパネルです +@msg unknown pieLegend `{v}` (none, standard or wedges) `{v}` は不明な pieLegend の指定です (none、standard、wedges のいずれか) +@msg unknown piePosition `{v}` (even, ratio or stacked) `{v}` は不明な piePosition の指定です (even、ratio、stacked のいずれか) +@msg unknown pieSort `{v}` (none, all or first) `{v}` は不明な pieSort の指定です (none、all、first のいずれか) +@msg unknown plate `{n}` `{n}` は不明な版です +@msg unknown preference `{k}` `{k}` は不明な環境設定です +@msg unknown profile {id} (see stroke.widthProfile.list) {id} は不明なプロファイルです (stroke.widthProfile.list を参照) +@msg unknown proof target `{t}` `{t}` は不明な校正ターゲットです +@msg unknown quotes key `{k}` (double, single) `{k}` は不明な quotes のキーです (double、single のいずれか) +@msg unknown resolution `{v}` `{v}` は不明な解像度です +@msg unknown rule `{rule}` ({_1}) `{rule}` は不明なルールです ({_1}) +@msg unknown rule `{r}` (see color.harmony) `{r}` は不明なルールです (color.harmony を参照) +@msg unknown setting `{k}` `{k}` は不明な設定です +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) `{v}` は不明なシェイプです (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) `{o}` は不明な間隔の指定です (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) `{t}` は不明な tileType です ({_1}) +@msg unknown units 不明な単位です +@msg unknown units `{u}` `{u}` は不明な単位です +@msg unknown value axis `{v}` (left, right or both) `{v}` は不明な数値軸の指定です (left、right、both のいずれか) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) `{v}` は不明なバリエーションです (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` `{s}` は不明なワープスタイルです +@msg unknown {key} `{v}` (none, short or full) `{v}` は不明な {key} の指定です (none、short、full のいずれか) +@msg unknown {key} profile `{n}` `{n}` は不明な {key} プロファイルです +@msg unknown {k} key `{key}` (size, position) `{key}` は不明な {k} のキーです (size、position のいずれか) +@msg unreadable object 読み込めないオブジェクトです +@msg unsafe destination file name 出力先のファイル名が安全ではありません +@msg use paths or compound paths パスまたは複合パスを使用してください +@msg useArtboards must be true or false, not {v} useArtboards には true または false を指定してください ({v} は指定できません) +@msg value for `{var}` must match its variable kind `{var}` の値は変数の種類に合わせてください +@msg variable `{_1}` is already defined 変数 `{_1}` はすでに定義されています +@msg variable `{name}` has no `trait` 変数 `{name}` に `trait` がありません +@msg variable `{name}` is already defined 変数 `{name}` はすでに定義されています +@msg variable `{name}` is declared twice 変数 `{name}` が 2 回宣言されています +@msg variable `{to}` already exists 変数 `{to}` はすでにあります +@msg view is required view は必須です +@msg view must be one of {_1} view には {_1} のいずれかを指定してください +@msg warpStyle can only edit an applied Warp effect warpStyle で編集できるのは、適用済みのワープ効果だけです +@msg width and height must be more than 0 and at most {limit} width と height には 0 より大きく {limit} 以下の値を指定してください +@msg width factors must be finite 幅の係数には有限の値を指定してください +@msg width must be positive width には正の値を指定してください +@msg width out of range 幅が範囲外です +@msg width/height must be > 0 width/height には 0 より大きい値を指定してください +@msg x and y must be finite numbers x と y には有限の数値を指定してください +@msg x and y must be given together x と y は一緒に指定してください +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone には anchor が必要です: anchor でパネルの横にドッキングするか、side (Start または End) で新しいドック列にドッキングしてください +@msg zoom must be positive zoom には正の値を指定してください +@msg {C}: no effect at index {list} {C}: インデックス {list} に効果はありません +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} は保護されているため、削除されません +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} は保護されています: 設定を別の名前で保存してください +@msg {LABEL} produced an empty result {LABEL} の結果が空になりました +@msg {_1} MB of document, at most {_2} MB ドキュメントが {_1} MB あります (上限は {_2} MB) +@msg {_1} MB of images, at most {_2} MB 画像が {_1} MB あります (上限は {_2} MB) +@msg {_1} bytes of RGBA data for a {width} × {height} image {width} × {height} の画像に対して RGBA データが {_1} バイトあります +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} を読み込めないか、読み込み後に変更されています: 先に再リンクまたは更新してください +@msg {_1} can't go into {into} {_1} を {into} に入れることはできません +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} ではプリントを低解像度に制限できません: PDF 1.4 以降を選択するか、高解像度プリントを許可してください +@msg {_1} files can't be opened yet {_1} ファイルはまだ開けません +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1} ファイルはパスワードで保護できません: パスワードを削除するか、規格なしを選択してください +@msg {_1} files can't be {_2} {_1} ファイルは {_2} にできません +@msg {_1} files can't carry editing data: turn off preserveEditing {_1} ファイルには編集データを含められません: preserveEditing をオフにしてください +@msg {_1} files can't have PDF layers: turn off createLayers {_1} ファイルには PDF レイヤーを含められません: createLayers をオフにしてください +@msg {_1} files need a creation date, and this platform has no clock to give one {_1} ファイルには作成日が必要ですが、このプラットフォームには日時を取得できる時計がありません +@msg {_1} isn't a path {_1} はパスではありません +@msg {_1} options: {e} {_1} オプション: {e} +@msg {_1} paints nothing to rasterize {_1} にはラスタライズできる描画内容がありません +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1} のパスワードは ASCII 文字で {MAX_LEGACY_PASSWORD} 文字以内です (PDF 1.7 以降では任意の文字を使用できます) +@msg {_1} with linked images or an image map writes several files: use document.export リンク画像またはイメージマップのある {_1} は複数のファイルを書き出します: document.export を使用してください +@msg {_1} writes one file per artboard here: use document.export, or name one artboard ここでは {_1} はアートボードごとに 1 つのファイルを書き出します: document.export を使用するか、アートボードを 1 つ指定してください +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} ピクセルは {_3} には大きすぎます (1 辺あたり最大 {max} ピクセル): 拡大・縮小率を下げてください +@msg {_1}: belongs to another account {_1}: 別のアカウントのものです +@msg {_1}: not a folder of its own {_1}: 専用のフォルダーではありません +@msg {_1}: not a plain file {_1}: 通常のファイルではありません +@msg {dest}: {e} {dest}: {e} +@msg {doc} has no folder {doc} にフォルダーがありません +@msg {file} isn't linked {file} はリンクされていません +@msg {folder} is not a package folder {folder} はパッケージフォルダーではありません +@msg {folder}: more than {max} files and folders {folder}: ファイルとフォルダーが {max} 個を超えています +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} はライブペイントグループではありません +@msg {id} is not a slice {id} はスライスではありません +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} には 0–{MAX_ADD_AROUND} pt を指定してください +@msg {key} must be [x,y] {key} には [x,y] を指定してください +@msg {key} must be a percentage {min}..100 {key} には {min}..100 のパーセント値を指定してください +@msg {key} must be a positive number, not {n} {key} には正の数値を指定してください ({n} は指定できません) +@msg {key} must be a positive number, not {v} {key} には正の数値を指定してください ({v} は指定できません) +@msg {key} must be one of {_1} {key} には {_1} のいずれかを指定してください +@msg {key} must be {_1}, got {s} {key} には {_1} を指定してください (指定値: {s}) +@msg {key} must name a floating panel {key} にはフローティングパネルを指定してください +@msg {key} must name a panel in another group {key} には別のグループにあるパネルを指定してください +@msg {key} must name a panel that shows {key} には表示されているパネルを指定してください +@msg {key} must name a registered panel {key} には登録済みのパネルを指定してください +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} ファイルは透明に対応していませんが、ファイルに {what} が含まれます: 先に分割・統合してください (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind} 画像は開けません (document.formats を参照) +@msg {k} `{v}` is not one of the choices {k} `{v}` は選択肢にありません +@msg {k} has control characters{_1} {k} に制御文字{_1} が含まれています +@msg {k} is too long (at most {MAX_TEXT} characters) {k} が長すぎます (最大 {MAX_TEXT} 文字) +@msg {k} must be > 0 {k} には 0 より大きい値を指定してください +@msg {k} must be a number (pt) or a length such as "210 mm" {k} には数値 (pt)、または "210 mm" のような長さを指定してください +@msg {k} must be a number from {min} to {max} {k} には {min} から {max} までの数値を指定してください +@msg {k} must be a number or "auto" {k} には数値または "auto" を指定してください +@msg {k} must be a string {k} には文字列を指定してください +@msg {k} must be text {k} にはテキストを指定してください +@msg {k} must be true or false {k} には true または false を指定してください +@msg {k} must be {{size?, position?}} {k} には {{size?, position?}} を指定してください +@msg {k}: can't read `{s}` as a length {k}: `{s}` を長さとして読み取れません +@msg {label} must be a number at most {max} {label} には {max} 以下の数値を指定してください +@msg {label} must be a number from -1296 to 1296 {label} には -1296 から 1296 までの数値を指定してください +@msg {label} must be a number from 1 to 10000 {label} には 1 から 10000 までの数値を指定してください +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} は組み込みのため、削除できません +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} には {lo}–{hi}{unit} を指定してください (指定値: {v}) +@msg {name}: CRC or size mismatch {name}: CRC またはサイズが一致しません +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: この Affinity ファイルは埋め込みプレビューしか読み込めませんでした。「ファイル › 開く」で警告とともに表示するか、配置する前に Affinity から SVG または PDF を書き出してください +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}。埋め込みプレビューも読み込めません ({e}) +@msg {std} files are CMYK, and the file would have colours in {_1} {std} ファイルは CMYK ですが、ファイルに {_1} のカラーが含まれます +@msg {std} files embed their fonts, and the file would have one that isn't {std} ファイルにはフォントを埋め込みますが、ファイルに埋め込まれないフォントが含まれます +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} はこのドキュメントの読み込み元のファイルで、読み込み時に除外された内容があります ({what}): 上書きすると、それらは完全に失われます。別のファイルに書き出すか、それでも置き換える場合は acknowledgeLoss: true を指定してください +@msg {ty} brushes need selected art (or params.{k}) {ty} ブラシには選択したアート (または params.{k}) が必要です +@msg {v} is not an asset id {v} はアセット ID ではありません +@msg {what} can't be intertwined {what} は織り交ぜられません +@msg {what} doesn't fit the paper with its bleed and marks {what} は、裁ち落としとトンボを含めると用紙に収まりません +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} は {MAX_TILES} 枚を超えるタイルにプリントされます: 縮小してください Playback Options 再生オプション Accelerated 高速 Step by Step ステップ実行 @@ -7165,6 +8972,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 保留中の効果を更新 @@ -7194,3 +9007,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 fc649cd87a..55f726dc38 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1446,6 +1446,7 @@ lighting Light სინათლე Proportional Metrics პროპორციული მეტრიკა Insert Aki Left აკის ჩასმა მარცხნივ Insert Aki Right აკის ჩასმა მარჯვნივ + Tsume ცუმე Character Direction სიმბოლოების მიმართულება Digits ციფრები Arabic არაბული @@ -4424,6 +4425,7 @@ font-technology Variable ცვლადი @msg no action set to save შესანახი მოქმედებების ნაკრები არ არის @msg the action set file can't be read in full მოქმედებების ნაკრების ფაილის სრულად წაკითხვა შეუძლებელია @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n} +@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}“; შენახული ნაბიჯები უცნობი ბრძანებით: {n}; პირველი: „{action}“, ნაბიჯი {step}, `{command}` @msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი @msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: ცნობარი {MAX_BYTES} ბაიტზე დიდი იქნებოდა @@ -4643,6 +4645,7 @@ font-technology Variable ცვლადი @msg tool shortcuts must be a single key (optionally with Shift) ხელსაწყოს მალსახმობი ერთი კლავიში უნდა იყოს (სურვილისამებრ Shift-ით) @msg truncated შეკვეცილია @msg unknown command `{0}` უცნობი ბრძანება `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` „{action}“, ნაბიჯი {step}: უცნობი ბრძანება `{command}` @msg unknown command `{0}`; did you mean `{1}`? უცნობი ბრძანება `{0}`; იქნებ `{1}` იგულისხმეთ? @msg unknown effect `{id}` უცნობი ეფექტი `{id}` @msg unknown format უცნობი ფორმატი @@ -6344,8 +6347,8 @@ axis Y Axis Y ღერძი @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ექსპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ჯგუფების გარეთ მაქსიმუმ {MAX_UNGROUPED} პარამეტრია დასაშვები: დანარჩენებს მიეცით `group` @msg can't encode the input შეყვანის კოდირება ვერ ხერხდება -@msg expected {"dataBase64"} მოსალოდნელი იყო {"dataBase64"} -@msg expected {"objects"} მოსალოდნელი იყო {"objects"} +@msg expected {{"dataBase64": "…"}} მოსალოდნელი იყო {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} მოსალოდნელი იყო {{"objects": [...]}} @msg export `{name}`: {e} მოდულის ექსპორტი `{name}`: {e} @msg extension {e} is listed twice გაფართოება {e} ორჯერაა მითითებული @msg extension {e}: use 1-16 lower-case letters and digits, without the dot გაფართოება {e}: გამოიყენეთ 1-16 პატარა ასო და ციფრი, წერტილის გარეშე @@ -6409,11 +6412,11 @@ axis Y Axis Y ღერძი @msg a painted live effect returns only `objects` მხატვრული ცოცხალი ეფექტი აბრუნებს მხოლოდ `objects`-ს @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` მხატვრული ცოცხალი ეფექტის ნაწილს აქვს მხოლოდ `path`, `fillRule`, `fills` და `strokes` @msg a read-only object was left out გამოტოვებულია მხოლოდ წასაკითხი ობიექტი -@msg a read-only object was left out: return it, as {"id"} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {"id"}-ის სახით ან ისე, როგორც მიიღეთ +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {{"id": …}}-ის სახით ან ისე, როგორც მიიღეთ @msg a string სტრიქონი @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` კონტურს აქვს მხოლოდ `paint`, `width`, `join`, `cap` და `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} კონტური უნდა იყოს {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ეფექტი უნდა იყოს {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} კონტური უნდა იყოს {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ეფექტი უნდა იყოს {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` შევსების ეფექტი აბრუნებს მხოლოდ `fills`-ს @msg an effect on a stroke returns only `strokes` კონტურის ეფექტი აბრუნებს მხოლოდ `strokes`-ს @msg an object must be a JSON object ობიექტი უნდა იყოს JSON ობიექტი @@ -7359,7 +7362,7 @@ font-script Japanese იაპონური Japanese Envelope #3 იაპონური კონვერტი #3 Japanese Envelope #4 იაპონური კონვერტი #4 @msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა -@msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით +@msg not an RTF file: it doesn't start with {{\\rtf RTF ფაილი არ არის: არ იწყება {{\\rtf-ით # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools ტექსტის ხელსაწყოები Text Tweaker… ტექსტის მორგება… @@ -7583,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 71a45e307d..abe2b4bc28 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -46,6 +46,7 @@ font-technology Variable 가변 Proportional Metrics 비례 메트릭 Insert Aki Left 앞 아키 삽입 Insert Aki Right 뒤 아키 삽입 + Tsume 츠메 Character Direction 문자 방향 Digits 숫자 Arabic 아라비아어 @@ -4641,6 +4642,7 @@ theme Appearance 외관 @msg objects can't be nested more than 32 deep 개체는 32단계보다 깊게 중첩할 수 없습니다 @msg the SVG's elements are nested more than {0} deep SVG 요소가 {0}단계보다 깊게 중첩되어 있습니다 @msg unknown command `{0}` 알 수 없는 명령 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}”의 단계 {step}에서 알 수 없는 명령 `{command}`을(를) 실행합니다 @msg unknown command `{0}`; did you mean `{1}`? 알 수 없는 명령 `{0}`: `{1}`을(를) 의미하셨습니까? @msg `{what}` closes nothing `{what}`이(가) 닫는 항목이 없습니다 @msg `{what}` closes something it didn't open `{what}`이(가) 열지 않은 항목을 닫습니다 @@ -4990,6 +4992,7 @@ theme Appearance 외관 @msg no action set to save 저장할 액션 세트가 없습니다 @msg the action set file can't be read in full 액션 세트 파일을 전부 읽을 수 없습니다 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다 +@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}”을(를) 불러왔습니다. 알 수 없는 명령이 있는 단계 {n}개를 그대로 두었습니다. 첫 번째: “{action}”, 단계 {step}, `{command}` @msg not an action set file 액션 세트 파일이 아닙니다 @msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다 @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: 참조가 {MAX_BYTES}바이트보다 커집니다 @@ -5980,8 +5983,8 @@ axis Y Axis Y축 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 내보내기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다 @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 그룹 밖의 매개변수는 최대 {MAX_UNGROUPED}개입니다. 나머지에는 `group`을 지정하세요 @msg can't encode the input 입력을 인코딩할 수 없습니다 -@msg expected {"dataBase64"} {"dataBase64"} 형식이어야 합니다 -@msg expected {"objects"} {"objects"} 형식이어야 합니다 +@msg expected {{"dataBase64": "…"}} {{"dataBase64": "…"}} 형식이어야 합니다 +@msg expected {{"objects": [...]}} {{"objects": [...]}} 형식이어야 합니다 @msg export `{name}`: {e} 모듈 내보내기 `{name}`: {e} @msg extension {e} is listed twice 확장자 {e}이(가) 두 번 나열되었습니다 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 확장자 {e}: 점 없이 소문자와 숫자 1-16자를 사용하세요 @@ -6045,11 +6048,11 @@ axis Y Axis Y축 @msg a painted live effect returns only `objects` 페인트형 라이브 효과는 `objects`만 반환합니다 @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 페인트형 라이브 효과의 조각에는 `path`, `fillRule`, `fills`, `strokes`만 있습니다 @msg a read-only object was left out 읽기 전용 개체가 빠졌습니다 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 읽기 전용 개체가 빠졌습니다: {"id"}만 있는 형태나 받은 그대로 반환하십시오 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 읽기 전용 개체가 빠졌습니다: {{"id": …}}만 있는 형태나 받은 그대로 반환하십시오 @msg a string 문자열 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 선에는 `paint`, `width`, `join`, `cap`, `miterLimit`만 있습니다 -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 선은 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 형태여야 합니다 -@msg an effect must be {"id", "params"?, "visible"?} 효과는 {"id", "params"?, "visible"?} 형태여야 합니다 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 선은 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 형태여야 합니다 +@msg an effect must be {{"id", "params"?, "visible"?}} 효과는 {{"id", "params"?, "visible"?}} 형태여야 합니다 @msg an effect on a fill returns only `fills` 칠에 적용된 효과는 `fills`만 반환합니다 @msg an effect on a stroke returns only `strokes` 선에 적용된 효과는 `strokes`만 반환합니다 @msg an object must be a JSON object 개체는 JSON 개체여야 합니다 @@ -7060,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 보류된 효과 새로 고침 @@ -7089,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 a0953094fa..ed281382a4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -290,8 +290,8 @@ @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` een exportplug-in declareert 1 tot {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` maximaal {MAX_UNGROUPED} parameters buiten groepen: geef de andere een `group` @msg can't encode the input de invoer kan niet worden gecodeerd -@msg expected {"dataBase64"} verwacht: {"dataBase64"} -@msg expected {"objects"} verwacht: {"objects"} +@msg expected {{"dataBase64": "…"}} verwacht: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} verwacht: {{"objects": [...]}} @msg export `{name}`: {e} export `{name}`: {e} @msg extension {e} is listed twice extensie {e} staat twee keer in de lijst @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensie {e}: gebruik 1-16 kleine letters en cijfers, zonder de punt @@ -355,11 +355,11 @@ @msg a painted live effect returns only `objects` een geschilderd live-effect geeft alleen `objects` terug @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` een stuk van een geschilderd live-effect heeft alleen `path`, `fillRule`, `fills` en `strokes` @msg a read-only object was left out een alleen-lezen object is weggelaten -@msg a read-only object was left out: return it, as {"id"} alone or as it was given een alleen-lezen object is weggelaten: geef het terug, als alleen {"id"} of zoals het is gegeven +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given een alleen-lezen object is weggelaten: geef het terug, als alleen {{"id": …}} of zoals het is gegeven @msg a string een string @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` een contour heeft alleen `paint`, `width`, `join`, `cap` en `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} een contour moet {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} zijn -@msg an effect must be {"id", "params"?, "visible"?} een effect moet {"id", "params"?, "visible"?} zijn +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} een contour moet {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} zijn +@msg an effect must be {{"id", "params"?, "visible"?}} een effect moet {{"id", "params"?, "visible"?}} zijn @msg an effect on a fill returns only `fills` een effect op een vulling geeft alleen `fills` terug @msg an effect on a stroke returns only `strokes` een effect op een contour geeft alleen `strokes` terug @msg an object must be a JSON object een object moet een JSON-object zijn @@ -2027,6 +2027,7 @@ lighting Light Licht Proportional Metrics Proportionele metingen Insert Aki Left Aki links invoegen Insert Aki Right Aki rechts invoegen + Tsume Tsume Character Direction Tekenrichting Digits Cijfers Arabic Arabisch @@ -5111,6 +5112,7 @@ imageTrace Outline Contour @msg no action set to save geen actieset om op te slaan @msg the action set file can't be read in full het actiesetbestand kan niet volledig worden gelezen @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” geladen: {n} stap is weggelaten|„{name}” geladen: {n} stappen zijn weggelaten +@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}” geladen: {n} stap heeft een onbekende opdracht en is behouden: „{action}”, stap {step}, `{command}`|„{name}” geladen: {n} stappen hebben onbekende opdrachten en zijn behouden; de eerste: „{action}”, stap {step}, `{command}` @msg not an action set file geen actiesetbestand @msg the action set file is larger than 8 MiB het actiesetbestand is groter dan 8 MiB @msg the action set file holds more than 1,000 actions het actiesetbestand bevat meer dan 1.000 acties @@ -5312,6 +5314,7 @@ imageTrace Outline Contour @msg tool shortcuts must be a single key (optionally with Shift) gereedschapsneltoetsen moeten uit één toets bestaan (eventueel met Shift) @msg truncated afgekapt @msg unknown command `{0}` onbekende opdracht `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` stap {step} van „{action}” voert de onbekende opdracht `{command}` uit @msg unknown command `{0}`; did you mean `{1}`? onbekende opdracht `{0}`; bedoelde je `{1}`? @msg unknown effect `{id}` onbekend effect `{id}` @msg unknown format onbekende indeling @@ -5561,6 +5564,1824 @@ imageTrace Outline Contour @msg missing panel layout ontbrekende paneelindeling @msg unsupported native docking operation niet-ondersteunde systeemeigen dokbewerking @msg this kind of brush has no options dialog yet dit soort penseel heeft nog geen dialoogvenster met opties + +# Status and error messages: the script engine's own errors, command parameters, file readers and +# writers, dialogs, panels and add-ons. Names in backticks, command ids, double-quoted values and +# bare true, false and null stay as the English writes them. +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 'caller', 'callee' en 'arguments' kunnen niet worden gebruikt bij functies in strikte modus +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Een profiel ondersteunt één effect Draaien. Bewerk in plaats daarvan de opties van het bestaande effect. +@msg Add Points at Crossings: a path went missing Punten toevoegen bij kruisingen: er is een pad verdwenen +@msg Alignment must be one of the choices De uitlijning moet een van de keuzes zijn +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} is aangeroepen op een waarde die geen Boolean is +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB CMYK-afbeeldingen waarvan de samples niet in CMYK konden worden gelezen, zijn in RGB geïmporteerd +@msg CSS options: {e} CSS-opties: {e} +@msg CSV has a header but no data rows De CSV heeft een koptekst maar geen gegevensrijen +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark De CSV bevat NUL-bytes: sla het bestand op als UTF-8, of als UTF-16 met een byte-order mark +@msg CSV is empty De CSV is leeg +@msg CSV is larger than {MAX_CSV_BYTES} bytes De CSV is groter dan {MAX_CSV_BYTES} bytes +@msg CSV is not UTF-16 De CSV is geen UTF-16 +@msg CSV is not UTF-8 De CSV is geen UTF-8 +@msg CSV would be larger than {MAX_CSV_BYTES} bytes De CSV zou groter worden dan {MAX_CSV_BYTES} bytes +@msg Cannot write to the location of “{name}”; choose where to save it Kan niet schrijven naar de locatie van „{name}”; kies waar je het wilt opslaan +@msg Choose original artwork, not a dimension's generated paths Kies de oorspronkelijke illustratie, niet de gegenereerde paden van een maatlijn +@msg Contour: select paths or shapes Omtrek: selecteer paden of vormen +@msg Contour: the Offset Path effect is not available Omtrek: het effect Pad verschuiven is niet beschikbaar +@msg Create needs fills, strokes or both Maken heeft vullingen, contouren of beide nodig +@msg DCS files can only be placed linked: use File › Place with Link on DCS-bestanden kunnen alleen gekoppeld worden geplaatst: gebruik Bestand › Plaatsen met Koppeling ingeschakeld +@msg DXF options: {e} DXF-opties: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} De DXF-schaal moet een positief getal tot en met {_1} zijn, niet {scale} +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Date.prototype method called on a value that is not a Date object Een methode van Date.prototype is aangeroepen op een waarde die geen Date-object is +@msg Dimension is locked or hidden De maatlijn is vergrendeld of verborgen +@msg Dimension mode does not match its source De modus van de maatlijn komt niet overeen met de bron ervan +@msg Draw Inside needs exactly one selected path, compound path or text Binnen tekenen heeft precies één geselecteerd pad, samengesteld pad of tekstobject nodig +@msg EPS Save writes all artwork: use document.export for selected-only EPS Opslaan als EPS schrijft alle illustraties: gebruik document.export voor een EPS met alleen de selectie +@msg EPS flattener: {e} EPS-afvlakker: {e} +@msg EPS options: {e} EPS-opties: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString is aangeroepen op een waarde die geen object is +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Exporteren voor schermen schrijft png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai of vectorcraft +@msg Flare click coordinates must be finite numbers De klikcoördinaten van de flare moeten eindige getallen zijn +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString is aangeroepen op een waarde die geen functie is +@msg HTML Text applies to object slices of type only HTML-tekst geldt alleen voor objectslices van tekst +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Objecten identificeren op staat op XML-id: de naam moet een geldige XML-id zijn (begin met een letter, onderstrepingsteken of dubbele punt; geen spaties) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) Afbeelding traceren is mislukt: interne fout: de thread ervan is gestopt (meld deze bug alsjeblieft) +@msg Image Trace failed: {e} Afbeelding traceren is mislukt: {e} +@msg Image Trace finished, but its document was closed Afbeelding traceren is voltooid, maar het document ervan is gesloten +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Afbeelding traceren is voltooid, maar de afbeelding of de instellingen ervan zijn intussen gewijzigd: traceer de afbeelding opnieuw +@msg Import SVG Filter reads SVG files, not SVGZ SVG-filter importeren leest SVG-bestanden, geen SVGZ +@msg Invalid dimension offset Ongeldige verschuiving van de maatlijn +@msg Invalid point Ongeldig punt +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (op positie {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify kan geen structuur schrijven die zichzelf bevat +@msg Join: a subpath went missing Verbinden: er is een subpad verdwenen +@msg Line Gap must be a number De lijnafstand moet een getal zijn +@msg Lock Workspace is not available in VectorCraft Werkruimte vergrendelen is niet beschikbaar in VectorCraft +@msg Move Panel to New Window is not available: floating panels stay in the main window Paneel naar nieuw venster verplaatsen is niet beschikbaar: zwevende panelen blijven in het hoofdvenster +@msg Namespace.prototype.toString needs a Namespace Namespace.prototype.toString heeft een Namespace nodig +@msg New Document profiles need the preferences folder, which this run doesn't have Profielen voor nieuwe documenten hebben de voorkeurenmap nodig, die deze sessie niet heeft +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} is aangeroepen op een waarde die geen getal is +@msg Object.create's prototype must be an object or null Het prototype van Object.create moet een object of null zijn +@msg Object.{function} was given a value that is not an object Object.{function} kreeg een waarde die geen object is +@msg Offset Path: the path is too complex to offset Pad verschuiven: het pad is te complex om te verschuiven +@msg Offset Path: {e} Pad verschuiven: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported De PDF-inhoud is onvolledig of overschrijdt de limieten van de lezer; er is geen document geïmporteerd +@msg PDF flattener: {e} PDF-afvlakker: {e} +@msg PDF options: {e} PDF-opties: {e} +@msg QName.prototype.toString needs a QName QName.prototype.toString heeft een QName nodig +@msg Recolor Artwork is no longer open Artwork herkleuren is niet meer geopend +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} is aangeroepen op een waarde die geen RegExp-object is +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 De SVG is geen UTF-8, UTF-16 of ISO 8859-1 +@msg Save writes one artboard: name one, or export several with document.export Opslaan schrijft één tekengebied: noem er een, of exporteer er meerdere met document.export +@msg Select a dimension first Selecteer eerst een maatlijn +@msg Select dimensions to update Selecteer maatlijnen om bij te werken +@msg Select only dimensions Selecteer alleen maatlijnen +@msg Select two or more objects, or a group Selecteer twee of meer objecten, of een groep +@msg Shaper needs 1–64 source paths Shaper heeft 1–64 bronpaden nodig +@msg Stencil Bridges: the selected art is too large or too detailed to bridge Sjabloonbruggen: de geselecteerde illustratie is te groot of te gedetailleerd om te overbruggen +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString is aangeroepen op een waarde die geen string is +@msg Switch x/y is for scatter graphs x/y omwisselen is bedoeld voor spreidingsgrafieken +@msg Text options: {e} Tekstopties: {e} +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas De koppeling zit in een symbool, grafiekontwerp, patroon of dekkingmasker en heeft geen plaats op het canvas +@msg The {name} profile could not be read, so the new document has the default settings: {reason} Het profiel {name} kon niet worden gelezen, dus heeft het nieuwe document de standaardinstellingen: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time Overbrugging: meer dan {_1} paren objecten liggen dicht genoeg bij elkaar om te overbruggen, of te veel objecten delen een horizontaal bereik; overbrug minder objecten tegelijk +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Wiedkader en wiedlijnen: er past geen wiedlijn buiten de illustratie en het wiedkader is uitgeschakeld +@msg \\ at end of pattern \\ aan het einde van het patroon +@msg `alignOn` cannot be empty `alignOn` mag niet leeg zijn +@msg `alignOn` must be a string `alignOn` moet een string zijn +@msg `align` is start, center or end `align` is start, center of end +@msg `align` must be a string `align` moet een string zijn +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` moet een array van paren [subpath, anchor] zijn +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` moet ankerindexen bevatten (0, 1, 2…), niet {k} +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` moet ankerindexen bevatten (0, 1, 2…), niet {v} +@msg `answer` for this {kind} dialog must be one of: {_1} `answer` voor dit dialoogvenster {kind} moet een van de volgende zijn: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers` heeft meer dan {MAX_ANSWERS} antwoorden +@msg `answers` is larger than 1 MiB `answers` is groter dan 1 MiB +@msg `answers` must be a list of answers `answers` moet een lijst met antwoorden zijn +@msg `artboard` must be the index of an artboard of the document `artboard` moet de index van een tekengebied van het document zijn +@msg `artboardsAt` must be [x, y] `artboardsAt` moet [x, y] zijn +@msg `artboards` must be a list of artboard indices `artboards` moet een lijst met tekengebiedindexen zijn +@msg `artboards` names an artboard the document does not have `artboards` noemt een tekengebied dat het document niet heeft +@msg `artboards` names an artboard twice `artboards` noemt een tekengebied twee keer +@msg `asset` is the asset id (see assets.list) `asset` is de asset-id (zie assets.list) +@msg `at` must be [x, y] (got {at}) `at` moet [x, y] zijn (kreeg {at}) +@msg `at` must be a byte offset `at` moet een bytepositie zijn +@msg `at` must be a point [x, y] `at` moet een punt [x, y] zijn +@msg `autoLeading` must be a number `autoLeading` moet een getal zijn +@msg `background` must be "", "matte" or #rrggbb `background` moet "", "matte" of #rrggbb zijn +@msg `beside` needs `side` `beside` heeft `side` nodig +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` moet "none", "standard" of "forced" zijn +@msg `center` must be [x, y] `center` moet [x, y] zijn +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` moet "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" of "icfBottom" zijn +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` moet "default", "leftToRight" of "rightToLeft" zijn +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke` vereist `stroke: false`, `ids: []` en geen `stop` +@msg `clear` must be a list of type object ids, not {v} `clear` moet een lijst met id’s van tekstobjecten zijn, niet {v} +@msg `clear` must list type objects; {_1} is not one `clear` moet tekstobjecten bevatten; {_1} is er geen +@msg `clear` must not list `id` `clear` mag `id` niet bevatten +@msg `code` must be a whole number `code` moet een geheel getal zijn +@msg `colorMode` must be one of rgb, cmyk `colorMode` moet een van rgb, cmyk zijn +@msg `color` needs a `rule` to make the theme from `color` heeft een `rule` nodig om het thema mee te maken +@msg `colors` does not apply to the {name} reduction `colors` geldt niet voor de reductie {name} +@msg `colors` is empty `colors` is leeg +@msg `colors` must be a whole number from 2 to 256 `colors` moet een geheel getal van 2 tot 256 zijn +@msg `colors` must be at least 9 for the uniform reduction `colors` moet minstens 9 zijn voor de uniforme reductie +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` moet tussen 1 en {MAX_THEME_COLORS} liggen (kreeg {n}) +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` is vertical, uniform, repeating of sliding +@msg `columns` must be a whole number 1..50 `columns` moet een geheel getal 1..50 zijn +@msg `commands` must not be empty `commands` mag niet leeg zijn +@msg `contents` goes with `to` `contents` hoort bij `to` +@msg `corners` must be sharp, round or square, not `{other}` `corners` moet sharp, round of square zijn, niet `{other}` +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` moet ankerindexen bevatten (0, 1, 2…), niet {v} +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` moet een geheel getal van 0 tot {MAX_COUNT} zijn +@msg `count` must be a whole number from 1 to 100 `count` moet een geheel getal van 1 tot 100 zijn +@msg `dataBase64` is larger than the module limit `dataBase64` is groter dan de modulelimiet +@msg `dataBase64` is not valid base64 `dataBase64` is geen geldige base64 +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` kan niet worden gecombineerd met `ids`, `reverse` of `append` +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` en `childLimit` beperken de samenvatting: geef `summary: true` op +@msg `design` is a design name, or null for plain columns `design` is de naam van een ontwerp, of null voor gewone kolommen +@msg `design` is a design name, or null for the default marker `design` is de naam van een ontwerp, of null voor de standaardmarkering +@msg `destinationFolder` is required when destination is `folder` `destinationFolder` is vereist wanneer de bestemming `folder` is +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` moet `none`, `saveAndClose` of `folder` zijn +@msg `diameter` must be from 0.1 to 2500 pt `diameter` moet van 0,1 tot 2500 pt zijn +@msg `digits` must be one of {_1} `digits` moet een van {_1} zijn +@msg `dimImages` is a percentage or false `dimImages` is een percentage of false +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` moet "auto", "leftToRight" of "rightToLeft" zijn +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` moet een getal van 0 tot 100 zijn +@msg `each` is the value one design stands for, more than 0 `each` is de waarde waar één ontwerp voor staat, meer dan 0 +@msg `end` must be "start" or "end" `end` moet "start" of "end" zijn +@msg `ends` applies to joining exactly two paths `ends` geldt voor het verbinden van precies twee paden +@msg `event` must be one of {_1} `event` moet een van {_1} zijn +@msg `every` must be "all", "odd" or "even", not {v} `every` moet "all", "odd" of "even" zijn, niet {v} +@msg `expand` must be true or false `expand` moet true of false zijn +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` moet van {lo} tot {hi} pt zijn +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` moet 1 tot 1000 paden van lettertypebestanden bevatten die de laatste zoekopdracht van text.findFontFiles heeft gevonden +@msg `focal` and `radius` cannot be used together `focal` en `radius` kunnen niet samen worden gebruikt +@msg `focal` must be an array of [subpath, anchor] pairs `focal` moet een array van paren [subpath, anchor] zijn +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` noemt [{si}, {ai}], en dat is geen anker van het pad +@msg `folder` must be an absolute folder path `folder` moet een absoluut mappad zijn +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` moet een object met astags en waarden zijn, zoals {"wght"} +@msg `format` must be a string `format` moet een string zijn +@msg `format` must be one of {_1} `format` moet een van {_1} zijn +@msg `fractions` is chop or scale `fractions` is chop of scale +@msg `from` and `to` must differ `from` en `to` moeten verschillen +@msg `from` has an empty frame `from` heeft een leeg kader +@msg `from` must be a non-negative integer `from` moet een niet-negatief geheel getal zijn +@msg `from` must be area type or type on a path `from` moet vlaktekst of tekst op een pad zijn +@msg `gid` must be a glyph id, not {v} `gid` moet een glyph-id zijn, niet {v} +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` hoort bij één teken, niet bij een tekenstijl (text.setGlyphAlternate stelt het in) +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` accepteert alleen null, dat een glyphkeuze wegneemt (text.setGlyphAlternate maakt er een) +@msg `group` must be one of {_1} `group` moet een van {_1} zijn +@msg `group` must be the Objects on Path group of `member` and `target` `group` moet de groep Objecten op pad van `member` en `target` zijn +@msg `guide` must be a guide index (guide.list) `guide` moet een hulplijnindex zijn (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` moet default, left, center of right zijn +@msg `id` is an object id, not {v} `id` is een object-id, niet {v} +@msg `id` must be a node id `id` moet een knooppunt-id zijn +@msg `id` must be an object id, not {v} `id` moet een object-id zijn, niet {v} +@msg `ids` must name Objects on Path groups or objects in them `ids` moet groepen Objecten op pad of objecten daarin noemen +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` accepteert maximaal {MAX_INSPECT_IDS} id’s +@msg `imageMap` must be none, rectangle or polygon `imageMap` moet none, rectangle of polygon zijn +@msg `includeArtboardFill` must be true or false `includeArtboardFill` moet true of false zijn +@msg `includeSubdirs` must be true or false `includeSubdirs` moet true of false zijn +@msg `index` must be a point index `index` moet een puntindex zijn +@msg `index` must be a whole number or null `index` moet een geheel getal of null zijn +@msg `indices` must be a non-empty array of item indices `indices` moet een niet-lege array van itemindexen zijn +@msg `item` needs `index` `item` heeft `index` nodig +@msg `items` is a list of {kind, item} `items` is een lijst van {kind, item} +@msg `items` is empty `items` is leeg +@msg `items` must be a list of {id, points} `items` moet een lijst van {id, points} zijn +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` moet een lijst van {{id, anchors?}} zijn, niet {v} +@msg `items` must be an array of {id, anchors} `items` moet een array van {id, anchors} zijn +@msg `items` must be an array of {{id, corners?}}, not {v} `items` moet een array van {{id, corners?}} zijn, niet {v} +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` moet "none", "short", "medium", "long" of "stylistic" zijn +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` hoort bij het eerste teken van een regel, niet bij een tekenstijl (text.setRangeStyle stelt het in) +@msg `kind` is one of {_1} `kind` is een van {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` moet "round", "invertedRound" of "chamfer" zijn, niet {v} +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` moet `text`, `visibility`, `image` of `graph` zijn +@msg `kind` must be a string `kind` moet een string zijn +@msg `kind` must be image, noImage or htmlText `kind` moet image, noImage of htmlText zijn +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` moet "pushInFirst", "pushOutFirst" of "pushOutOnly" zijn +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` moet "none", "hard" of "soft" zijn +@msg `label` must be a non-empty name of at most 200 characters `label` moet een niet-lege naam van maximaal 200 tekens zijn +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` moet een taalnaam of -tag zijn, zoals "Turkish" of "tr" +@msg `leader` must be a string `leader` moet een string zijn +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` moet "romanBaseline" of "emBoxTop" zijn +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` moet van 0,1 tot 5000 zijn, of null voor Automatisch +@msg `length` must be a number of at most 1000000 pt either way `length` moet een getal van maximaal 1000000 pt in beide richtingen zijn +@msg `line` must be true or false `line` moet true of false zijn +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` moet 1 tot {MAX_SUGGESTIONS} zijn +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) mag niet groter zijn dan `maxAngle` ({max_angle}°) +@msg `mode` must be "handles", "straight" or "smooth" `mode` moet "handles", "straight" of "smooth" zijn +@msg `mode` must be "straight", "bezier" or "arc" `mode` moet "straight", "bezier" of "arc" zijn +@msg `mode` must be one of {_1} `mode` moet een van {_1} zijn +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` moet "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" of "lineEndHalf" zijn +@msg `name` must be a name that is not empty `name` moet een naam zijn die niet leeg is +@msg `name` must not be empty `name` mag niet leeg zijn +@msg `names` is a list of preset names `names` is een lijst met namen van voorinstellingen +@msg `names` lists preset names `names` bevat namen van voorinstellingen +@msg `names` must be a list of names `names` moet een lijst met namen zijn +@msg `names` must be a list of swatch names `names` moet een lijst met staalnamen zijn +@msg `names` must be an array of style names `names` moet een array van stijlnamen zijn +@msg `new` is a design name, or true `new` is de naam van een ontwerp, of true +@msg `origin` must be [x, y] in points `origin` moet [x, y] in punten zijn +@msg `path` must be a folder `path` moet een map zijn +@msg `path` must be a path object `path` moet een padobject zijn +@msg `picks` must be an array (got {v}) `picks` moet een array zijn (kreeg {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` moet 1 tot {MAX_PICKS} keuzes bevatten (kreeg {_1}) +@msg `pivot` must be a whole number from 0 to 8 `pivot` moet een geheel getal van 0 tot 8 zijn +@msg `points` must be a list of [x, y] points `points` moet een lijst met punten [x, y] zijn +@msg `points` must be a list of point indexes `points` moet een lijst met puntindexen zijn +@msg `points` must be an array of point indices `points` moet een array van puntindexen zijn +@msg `points` must be point indexes `points` moet uit puntindexen bestaan +@msg `points` must hold from 2 to 100000 points `points` moet 2 tot 100000 punten bevatten +@msg `port` must be "in" or "out" `port` moet "in" of "out" zijn +@msg `position` is above or below `position` is above of below +@msg `preset` must be a string `preset` moet een string zijn +@msg `radius` must be 0 or more `radius` moet 0 of meer zijn +@msg `ratio` must be from 0.1 to 0.5 `ratio` moet van 0,1 tot 0,5 zijn +@msg `rect` must be [x, y, width, height] `rect` moet [x, y, width, height] zijn +@msg `rect` must be [x, y, width, height] with a positive size `rect` moet [x, y, width, height] met een positieve grootte zijn +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` moet [x0, y0, x1, y1] zijn (kreeg {r}) +@msg `relation` must be overlap, inside or outside `relation` moet overlap, inside of outside zijn +@msg `replace` must be a string `replace` moet een string zijn +@msg `reversed` must be true or false `reversed` moet true of false zijn +@msg `rotate` must be a finite number `rotate` moet een eindig getal zijn +@msg `rows` must be lists of numbers or nulls, not {r} `rows` moet uit lijsten met getallen of null-waarden bestaan, niet {r} +@msg `rule` must be nonZero or evenOdd `rule` moet nonZero of evenOdd zijn +@msg `scale` must be a number from 0.001 to 1000 `scale` moet een getal van 0,001 tot 1000 zijn +@msg `scale` must be a number in (0, 100] `scale` moet een getal in (0, 100] zijn +@msg `scope.{key}` must be a non-empty array `scope.{key}` moet een niet-lege array zijn +@msg `scope` must be "document", {artboards} or {layers} `scope` moet "document", {artboards} of {layers} zijn +@msg `scope` must be "selection" or "document", not {other} `scope` moet "selection" of "document" zijn, niet {other} +@msg `script` must be a string `script` moet een string zijn +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` is een niet-lege lijst met reeksindexen +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` heeft een `type` of een `valueAxis` nodig +@msg `shift` must be a number of points `shift` moet een aantal punten zijn +@msg `showCenter` must be true or false `showCenter` moet true of false zijn +@msg `side` must be "left", "right", "top" or "bottom" `side` moet "left", "right", "top" of "bottom" zijn +@msg `side` places the artboard: leave out x, y, width and height `side` plaatst het tekengebied: laat x, y, width en height weg +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` moet "fullJustify", "alignLeft", "alignCenter" of "alignRight" zijn +@msg `size` is a percentage of the base text's size, 1 to 100 `size` is een percentage van de grootte van de basistekst, 1 tot 100 +@msg `source` and `target` are the same object `source` en `target` zijn hetzelfde object +@msg `source` is the only path in series group `target` `source` is het enige pad in reeksgroep `target` +@msg `source` must be `folder` or `datasets` `source` moet `folder` of `datasets` zijn +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` levert meer dan {MAX_COUNT} lijnen over het kader op +@msg `spacing` must be a number `spacing` moet een getal zijn +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` moet een aantal punten zijn, 0 of meer, of "auto" +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` moet meer dan 0 en maximaal {MAX_DISTANCE} pt zijn +@msg `start` must be a finite number `start` moet een eindig getal zijn +@msg `starts` must be an array of anchor indices (or nulls) `starts` moet een array van ankerindexen (of null-waarden) zijn +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` moet linked, embedded, missing of modified zijn, of een lijst daarvan +@msg `stop` must be a stop index `stop` moet een stopindex zijn +@msg `story` styles the whole story: give no `start` or `end` `story` maakt het hele artikel op: geef geen `start` of `end` +@msg `story` takes the whole story: give no `start` or `end` `story` neemt het hele artikel: geef geen `start` of `end` +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` geldt voor contouren (stroke: true) +@msg `style` must be roman or japanese `style` moet roman of japanese zijn +@msg `subpaths` must be two integer indices `subpaths` moet uit twee gehele indexen bestaan +@msg `symbol` must be a symbol name `symbol` moet een symboolnaam zijn +@msg `target` must be "object" or "contents", not {v} `target` moet "object" of "contents" zijn, niet {v} +@msg `target` must be an object of the same Objects on Path group as `member` `target` moet een object zijn uit dezelfde groep Objecten op pad als `member` +@msg `tint` applies to global and spot colours `tint` geldt voor globale kleuren en steunkleuren +@msg `tint` goes with a global or spot `swatch` `tint` hoort bij een `swatch` met een globale kleur of steunkleur +@msg `tint` must be a number (%) `tint` moet een getal zijn (%) +@msg `tint` needs a global or spot colour `tint` heeft een globale kleur of steunkleur nodig +@msg `to` is already threaded `to` is al gekoppeld +@msg `to` is the design's new name `to` is de nieuwe naam van het ontwerp +@msg `to` must be [x, y] `to` moet [x, y] zijn +@msg `to` must be a closed path, area type or type on a path other than `from` `to` moet een gesloten pad, vlaktekst of tekst op een pad zijn, anders dan `from` +@msg `to` must be a whole number `to` moet een geheel getal zijn +@msg `to` takes the rows of one object's stack, not aligned rows `to` accepteert de rijen van de stapel van één object, geen uitgelijnde rijen +@msg `tolerance` must be from 0 to 100 pt `tolerance` moet van 0 tot 100 pt zijn +@msg `useLab` must be true or false `useLab` moet true of false zijn +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` moet default, top, middle, baseline of bottom zijn +@msg `values` must be a list `values` moet een lijst zijn +@msg `values` must be an object `values` moet een object zijn +@msg `values` must name hex, rgb, cmyk, lab or gray `values` moet hex, rgb, cmyk, lab of gray noemen +@msg `values` names a kind twice `values` noemt een soort twee keer +@msg `version` must be 0 or {_1} `version` moet 0 of {_1} zijn +@msg `version` must be 0 or {newest} `version` moet 0 of {newest} zijn +@msg `warichuAlign` must be one of {_1} `warichuAlign` moet een van {_1} zijn +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` moet een geheel getal van {_1} tot {_2} zijn +@msg `weight` must be from 0.01 to 1000 pt `weight` moet van 0,01 tot 1000 pt zijn +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` moet een lijst met {_1} getallen zijn, één per rij (kreeg {v}) +@msg `xml` must be a string `xml` moet een string zijn +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}`, item {n}, geeft de alinea op byte {start} van {_1} andere inspringingen dan een eerder item +@msg `{KEY}` entry {n} must be an object `{KEY}`, item {n}, moet een object zijn +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}`, item {n}, stelt noch `leftIndent` noch `firstLineIndent` in +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}`, item {n}: `id` moet een object-id zijn +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}`, item {n}: `start` moet een bytepositie zijn +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}`, item {n}: `{k}` moet een getal van -{MAX_PARA} tot {MAX_PARA} pt zijn +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}`, item {n}: er begint geen alinea van {_1} op byte {start} +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}`, item {n}: onbekende sleutel `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}`, item {n}: {_1} is geen tekstobject +@msg `{KEY}` is empty `{KEY}` is leeg +@msg `{KEY}` must be a list `{KEY}` moet een lijst zijn +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` accepteert maximaal {MAX_PARAGRAPH_INDENTS} items (kreeg {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}` accepteert geen `{other}` ernaast +@msg `{_1}` can't be edited or removed `{_1}` kan niet worden bewerkt of verwijderd +@msg `{_1}` is built in and always listed `{_1}` is ingebouwd en staat altijd in de lijst +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` is geen lettertypebestand dat de laatste zoekopdracht van text.findFontFiles heeft gevonden +@msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` zou door het pakket worden overschreven: kies een andere map of naam +@msg `{base}` is larger than {_1} MB `{base}` is groter dan {_1} MB +@msg `{base}` is not in the DCS file's folder `{base}` staat niet in de map van het DCS-bestand +@msg `{file}` has no art to place `{file}` heeft geen illustratie om te plaatsen +@msg `{file}` is over {_1} MB `{file}` is groter dan {_1} MB +@msg `{f}` belongs to a VectorCraft that is running `{f}` hoort bij een VectorCraft die actief is +@msg `{f}` is the copy of a document open now `{f}` is de kopie van een document dat nu geopend is +@msg `{id}` is no writable format (see document.formats) `{id}` is geen schrijfbare indeling (zie document.formats) +@msg `{id}` isn't a loaded library `{id}` is geen geladen bibliotheek +@msg `{key}` has no colours `{key}` heeft geen kleuren +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` bevat meer dan {MAX_EDIT_WORDS} woorden +@msg `{key}` is a design name `{key}` is de naam van een ontwerp +@msg `{key}` is a design name or a list of them `{key}` is de naam van een ontwerp of een lijst daarvan +@msg `{key}` is longer than {MAX_NAME} characters `{key}` is langer dan {MAX_NAME} tekens +@msg `{key}` is out of range `{key}` valt buiten het bereik +@msg `{key}` must be 0 or more `{key}` moet 0 of meer zijn +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` moet [min, desired, max] of {{min?, desired?, max?}} zijn, in procenten +@msg `{key}` must be [x, y] numbers `{key}` moet uit getallen [x, y] bestaan +@msg `{key}` must be a list of words `{key}` moet een lijst met woorden zijn +@msg `{key}` must be a list, not {v} `{key}` moet een lijst zijn, niet {v} +@msg `{key}` must be a non-negative integer `{key}` moet een niet-negatief geheel getal zijn +@msg `{key}` must be a number or null, not {v} `{key}` moet een getal of null zijn, niet {v} +@msg `{key}` must be a number {_1}..{_2} `{key}` moet een getal {_1}..{_2} zijn +@msg `{key}` must be a whole number of 0 or more `{key}` moet een geheel getal van 0 of meer zijn +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` moet een geheel getal van minstens 0 zijn, niet {n} +@msg `{key}` must be an appearance item index (paint order) or null `{key}` moet de index van een vormgevingsitem (verfvolgorde) of null zijn +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` moet een array van asset-id’s zijn (zie assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` moet van 0 tot 180 graden zijn, niet {a} +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` moet van 0 tot {MAX_DISTANCE} pt zijn +@msg `{key}` must be from 0.01 to 1000 pt `{key}` moet van 0,01 tot 1000 pt zijn +@msg `{key}` must be from {_1} to {shift_max} `{key}` moet van {_1} tot {shift_max} zijn +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` moet van {lo} tot {hi} zijn, niet {v} +@msg `{key}` must be one of auto, always, never `{key}` moet een van auto, always, never zijn +@msg `{key}` must be one of miter, round, bevel `{key}` moet een van miter, round, bevel zijn +@msg `{key}` must be one word of at most {_1} characters `{key}` moet één woord van maximaal {_1} tekens zijn +@msg `{key}` must hold single words of at most {_1} characters `{key}` moet losse woorden van maximaal {_1} tekens bevatten +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` moet een object noemen dat aan het pad van een groep Objecten op pad is gekoppeld +@msg `{key}` names an object that is hidden or locked `{key}` noemt een object dat verborgen of vergrendeld is +@msg `{key}`: not a colour: {v} `{key}`: geen kleur: {v} +@msg `{k}.family` is required `{k}.family` is vereist +@msg `{k}` is a number 0..1 `{k}` is een getal 0..1 +@msg `{k}` is true or false `{k}` is true of false +@msg `{k}` isn't an export setting: {_1} `{k}` is geen exportinstelling: {_1} +@msg `{k}` must be a byte offset `{k}` moet een bytepositie zijn +@msg `{k}` must be a number `{k}` moet een getal zijn +@msg `{k}` must be a number of points `{k}` moet een aantal punten zijn +@msg `{k}` must be a string `{k}` moet een string zijn +@msg `{k}` must be a whole number `{k}` moet een geheel getal zijn +@msg `{k}` must be from {lo} to {hi} for {key} `{k}` moet van {lo} tot {hi} zijn voor {key} +@msg `{k}` must be {what}, not {v} `{k}` moet {what} zijn, niet {v} +@msg `{l}` isn't a global or spot colour `{l}` is geen globale kleur of steunkleur +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` is geen bitmap: gebruik clipboard.importSvg, importPdf of importText +@msg `{name}` can't be deleted `{name}` kan niet worden verwijderd +@msg `{name}` can't be duplicated `{name}` kan niet worden gedupliceerd +@msg `{name}` can't be edited `{name}` kan niet worden bewerkt +@msg `{name}` can't be edited this way `{name}` kan niet op deze manier worden bewerkt +@msg `{name}` can't be renamed `{name}` kan geen andere naam krijgen +@msg `{name}` can't name a folder `{name}` kan geen naam van een map zijn +@msg `{name}` has no art to place `{name}` heeft geen illustratie om te plaatsen +@msg `{name}` has no text to place `{name}` heeft geen tekst om te plaatsen +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text `{name}` in het Word-document is geen UTF-8- of UTF-16-tekst +@msg `{name}` in the Word document is not well-formed XML: {e} `{name}` in het Word-document is geen welgevormde XML: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep `{name}` in het Word-document nest elementen meer dan {MAX_NESTING} niveaus diep +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` is een ingebouwde voorinstelling en is alleen-lezen: sla deze onder een andere naam op +@msg `{name}` is a built-in preset: save under another name `{name}` is een ingebouwde voorinstelling: sla op onder een andere naam +@msg `{name}` is a built-in profile and stays `{name}` is een ingebouwd profiel en blijft bestaan +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` is een afzonderlijk bestand, dat alleen naast het pad van het DCS-bestand kan worden gevonden +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` is een tint van `{base}`: bewerk `{base}` (een tintstaal accepteert alleen een nieuwe naam) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` is een werkruimte: kies eerst een andere met edit.colorSettings +@msg `{name}` is built in and stays `{name}` is ingebouwd en blijft bestaan +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` accepteert een verf van zijn eigen soort (kleur, verloop of patroon) +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{n}` can't be deleted `{n}` kan niet worden verwijderd +@msg `{n}` can't be merged `{n}` kan niet worden samengevoegd +@msg `{n}` can't be moved `{n}` kan niet worden verplaatst +@msg `{n}` is not a colour swatch `{n}` is geen kleurstaal +@msg `{n}` isn't a solid colour `{n}` is geen effen kleur +@msg `{n}`: colour groups hold solid colours only `{n}`: kleurgroepen bevatten alleen effen kleuren +@msg `{other}` doesn't trace an image `{other}` traceert geen afbeelding +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: de verwijzingen van de stap kopiëren meer dan {_1} MB aan waarden +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` is geen astag: een tag bestaat uit vier tekens, zoals "wght" +@msg `{to}` is empty or already used `{to}` is leeg of al in gebruik +@msg `{u}` is no Unicode character `{u}` is geen Unicode-teken +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` is langer dan {MAX_TEXT} bytes +@msg `{what}` must be text `{what}` moet tekst zijn +@msg `{word}` isn't in the custom dictionary `{word}` staat niet in het aangepaste woordenboek +@msg a CMap has too many mappings een CMap heeft te veel toewijzingen +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard een DXF-tekening heeft één oorsprong: geef useArtboards: true op voor één tekening per tekengebied +@msg a PDF presets file with over {_1} presets een bestand met PDF-voorinstellingen met meer dan {_1} voorinstellingen +@msg a \\u escape needs four hexadecimal digits een \\u-escape heeft vier hexadecimale cijfers nodig +@msg a batch preview cannot switch documents een batchvoorvertoning kan niet van document wisselen +@msg a brush named `{nn}` exists er bestaat al een penseel met de naam `{nn}` +@msg a comma in an XPath expression must separate function arguments een komma in een XPath-expressie moet functieargumenten scheiden +@msg a container of the listed objects is gone een container van de vermelde objecten is verdwenen +@msg a content stream can't be read een inhoudsstroom kan niet worden gelezen +@msg a content stream isn't a stream een inhoudsstroom is geen stroom +@msg a design holds at most {_1} objects een ontwerp bevat maximaal {_1} objecten +@msg a design needs a name een ontwerp heeft een naam nodig +@msg a dictionary grew too large een woordenboek is te groot geworden +@msg a document holds at most {_1} SVG filters een document bevat maximaal {_1} SVG-filters +@msg a document keeps at most {_1} graph designs een document bewaart maximaal {_1} grafiekontwerpen +@msg a document keeps at most {_1} saved selections een document bewaart maximaal {_1} opgeslagen selecties +@msg a document keeps at most {_1} views een document bewaart maximaal {_1} weergaven +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files een document koppelt maximaal {MAX_SVG_SCRIPTS} JavaScript-bestanden +@msg a document with unsaved changes has no recovery copy een document met niet-opgeslagen wijzigingen heeft geen herstelkopie +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes de orde van een feConvolveMatrix-filter is {x}×{y}, waardoor de vermenigvuldiging van de kernelgrootte in usvg overloopt +@msg a font's data is too large de gegevens van een lettertype zijn te groot +@msg a font's subroutines are too large de subroutines van een lettertype zijn te groot +@msg a function declaration without a name een functiedeclaratie zonder naam +@msg a gradient preset is already called {n} er heet al een verloopvoorinstelling {n} +@msg a graph has at most {_1} series, so it transposes with at most that many categories een grafiek heeft maximaal {_1} reeksen, dus kan hij met hoogstens zoveel categorieën worden getransponeerd +@msg a host value has too many entries een hostwaarde heeft te veel items +@msg a host value is nested too deep een hostwaarde is te diep genest +@msg a layer can't be merged into its own sublayer een laag kan niet in zijn eigen sublaag worden samengevoegd +@msg a layer's form box isn't an array het kader van het formulier van een laag is geen array +@msg a layer's form can't be read het formulier van een laag kan niet worden gelezen +@msg a layer's form has no box het formulier van een laag heeft geen kader +@msg a layer's form has no dictionary het formulier van een laag heeft geen woordenboek +@msg a legend draws at most {MAX_CHIPS} swatches een legenda tekent maximaal {MAX_CHIPS} stalen +@msg a linked JavaScript file is outside the read roots een gekoppeld JavaScript-bestand staat buiten de leesroots +@msg a name names one asset: give multiple: false, or one object een naam noemt één asset: geef multiple: false, of één object +@msg a namespace declaration uses a reserved prefix or URI een naamruimtedeclaratie gebruikt een gereserveerd voorvoegsel of een gereserveerde URI +@msg a namespace qualifier is undefined een naamruimtekwalificatie is niet gedefinieerd +@msg a namespace with an empty URI cannot have a prefix een naamruimte met een lege URI kan geen voorvoegsel hebben +@msg a number has no digits een getal heeft geen cijfers +@msg a number's exponent has no digits de exponent van een getal heeft geen cijfers +@msg a number's fraction has no digits het breukdeel van een getal heeft geen cijfers +@msg a page can't be read een pagina kan niet worden gelezen +@msg a page object is used by something else een paginaobject wordt door iets anders gebruikt +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) een paginaminiatuur moet uit RGB-pixels bestaan, 1 tot {THUMBNAIL_SIZE} per zijde (kreeg {w} × {h}) +@msg a pattern layer doesn't end what it starts een patroonlaag sluit niet af wat hij begint +@msg a pattern layer's colors hold something else de kleuren van een patroonlaag bevatten iets anders +@msg a pattern named `{n}` exists er bestaat al een patroon met de naam `{n}` +@msg a plug-in with id {_1} is already installed er is al een plug-in met id {_1} geïnstalleerd +@msg a preset named `{n}` exists er bestaat al een voorinstelling met de naam `{n}` +@msg a print presets file with over {_1} presets een bestand met afdrukvoorinstellingen met meer dan {_1} voorinstellingen +@msg a profile is already called {n} er heet al een profiel {n} +@msg a property descriptor cannot have both a value or writable and a get or set een eigenschapsdescriptor kan niet zowel value of writable als get of set hebben +@msg a property descriptor must be an object een eigenschapsdescriptor moet een object zijn +@msg a property's {name} must be a function or undefined de {name} van een eigenschap moet een functie of undefined zijn +@msg a row can't go into itself een rij kan niet in zichzelf worden geplaatst +@msg a series goes on the left or the right value axis een reeks hoort op de linker- of de rechterwaardeas +@msg a start must be an anchor index ≥ 0 or null een start moet een ankerindex ≥ 0 of null zijn +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied een stroom erin zou tot meer dan 256 MB worden gedecodeerd, of de decodeerparameters ervan kunnen niet worden toegepast +@msg a string holds a control character een string bevat een besturingsteken +@msg a string holds an escape sequence JSON does not have een string bevat een escapereeks die JSON niet kent +@msg a string is not closed een string is niet afgesloten +@msg a style named `{name}` already exists er bestaat al een stijl met de naam `{name}` +@msg a style named `{to}` already exists er bestaat al een stijl met de naam `{to}` +@msg a swatch needs a colour, gradient or pattern een staal heeft een kleur, verloop of patroon nodig +@msg a symbol named `{n}` already exists er bestaat al een symbool met de naam `{n}` +@msg a theme has 1 to {THEME_SIZE} colours een thema heeft 1 tot {THEME_SIZE} kleuren +@msg a theme is already called {name} (replace: "{name}" overwrites it) er heet al een thema {name} (replace: "{name}" overschrijft het) +@msg a toolbar name has at most {MAX_NAME} characters de naam van een werkbalk heeft maximaal {MAX_NAME} tekens +@msg a view named `{_1}` exists er bestaat al een weergave met de naam `{_1}` +@msg a view named `{name}` exists er bestaat al een weergave met de naam `{name}` +@msg a workspace keeps at most {MAX} toolbars een werkruimte bewaart maximaal {MAX} werkbalken +@msg a {_1} series can't go in a {_2} graph een reeks van het type {_1} kan niet in een grafiek van het type {_2} staan +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-compatibel, de standaard) of "7" (oude .ai, versie 7) +@msg already editing an opacity mask er wordt al een dekkingmasker bewerkt +@msg already editing another object's opacity mask het dekkingmasker van een ander object wordt al bewerkt +@msg already in pattern editing mode de patroonbewerkingsmodus is al actief +@msg an SVG filter is named by its id: give `xml` with `name` een SVG-filter wordt bij zijn id genoemd: geef `xml` met `name` +@msg an XML element has two attributes with the same name een XML-element heeft twee attributen met dezelfde naam +@msg an XML value cannot be inserted into itself or its descendants een XML-waarde kan niet in zichzelf of in zijn afstammelingen worden ingevoegd +@msg an XML value's children cannot be assigned by position de onderliggende elementen van een XML-waarde kunnen niet op positie worden toegewezen +@msg an XML value's children cannot be deleted by position de onderliggende elementen van een XML-waarde kunnen niet op positie worden verwijderd +@msg an XMLList converts to XML only when it has one item een XMLList wordt alleen naar XML geconverteerd wanneer deze één item heeft +@msg an XPath node test is missing er ontbreekt een XPath-knooppunttest +@msg an XPath operand is missing er ontbreekt een XPath-operand +@msg an XPath operator is missing er ontbreekt een XPath-operator +@msg an XPath predicate or step cannot follow here hier kan geen XPath-predicaat of -stap volgen +@msg an XPath predicate or step follows a value that is not a node-set een XPath-predicaat of -stap volgt op een waarde die geen knooppuntenset is +@msg an XPath step is missing er ontbreekt een XPath-stap +@msg an artboard is listed twice een tekengebied staat twee keer in de lijst +@msg an empty artboard has no width or height to scale to een leeg tekengebied heeft geen breedte of hoogte om naar te schalen +@msg an image couldn't be decoded and is missing from the picture een afbeelding kon niet worden gedecodeerd en ontbreekt in het beeld +@msg an image is too large for DXF and was left out een afbeelding is te groot voor DXF en is weggelaten +@msg an image of {_1} MB, at most {_2} MB een afbeelding van {_1} MB, maximaal {_2} MB +@msg an image of {declared_w} × {declared_h} pixels is too large to draw een afbeelding van {declared_w} × {declared_h} pixels is te groot om te tekenen +@msg an image of {width} × {height} pixels is too large to draw een afbeelding van {width} × {height} pixels is te groot om te tekenen +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) een afbeelding van {w} × {h} pixels is te groot om te converteren (maximaal {_1} pixels) +@msg an index must be a whole number een index moet een geheel getal zijn +@msg an inline graphic has no glyphs een inline afbeelding heeft geen glyphs +@msg an invisible text form can't be read een formulier met onzichtbare tekst kan niet worden gelezen +@msg an object is missing er ontbreekt een object +@msg an object's header is missing de kop van een object ontbreekt +@msg an overprinting form can't be read een overdrukkend formulier kan niet worden gelezen +@msg an unread object has no readable bounds een ongelezen object heeft geen leesbare grenzen +@msg an unread object holds a frame of threaded type een ongelezen object bevat een kader van gekoppelde tekst +@msg an unread object is missing from its layer een ongelezen object ontbreekt in zijn laag +@msg anchor `smooth` must be true or false `smooth` van het anker moet true of false zijn +@msg anchor `{key}` must be [x, y] `{key}` van het anker moet [x, y] zijn +@msg anchor `{key}` must be a number `{key}` van het anker moet een getal zijn +@msg angle and roundness must be finite hoek en ronding moeten eindig zijn +@msg angles must be a list as long as pins (degrees or null) angles moet een lijst zijn die even lang is als pins (graden of null) +@msg appearance must be flat|center|edge appearance moet flat|center|edge zijn +@msg apply is "fill" or "stroke", not `{other}` apply is "fill" of "stroke", niet `{other}` +@msg apply was called on a value that is not a function apply is aangeroepen op een waarde die geen functie is +@msg area must be a closed polygon of 3 to 100000 [x, y] points area moet een gesloten polygoon van 3 tot 100000 punten [x, y] zijn +@msg area type in a thread cannot use Dynamic Text: {frames} vlaktekst in gekoppelde tekst kan geen dynamische tekst gebruiken: {frames} +@msg area type needs a path that encloses an area vlaktekst heeft een pad nodig dat een oppervlak omsluit +@msg area type {_1} is named twice vlaktekst {_1} wordt twee keer genoemd +@msg array literal too long arrayliteral te lang +@msg arrowAlign must be extend|tip, got {o} arrowAlign moet extend|tip zijn, kreeg {o} +@msg arrowScale must be [start, end] arrowScale moet [start, end] zijn +@msg artboard is a 0-based index artboard is een index die bij 0 begint +@msg artboard must be a 0-based index artboard moet een index zijn die bij 0 begint +@msg artboard {i} doesn't print tekengebied {i} wordt niet afgedrukt +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout moet {layout?, columns?, spacing?, rightToLeft?} zijn +@msg artboardLayout.columns must be 1 or more artboardLayout.columns moet 1 of meer zijn +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout moet gridByRow, gridByColumn, row of column zijn +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft moet true of false zijn +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing moet 0–{MAX_SPACING} pt zijn +@msg artboards must be a non-empty list artboards moet een niet-lege lijst zijn +@msg artboards must be a number artboards moet een getal zijn +@msg asset `{_1}` has no art to export asset `{_1}` heeft geen illustratie om te exporteren +@msg assignment to a constant binding toewijzing aan een constante binding +@msg at least one row that isn't excluded needs a weight above 0 minstens één rij die niet is uitgesloten heeft een gewicht boven 0 nodig +@msg at most {MAX_KEYWORDS} keywords maximaal {MAX_KEYWORDS} trefwoorden +@msg at most {MAX_MATCHES} matches are kept er worden maximaal {MAX_MATCHES} overeenkomsten bewaard +@msg at most {MAX_POINTS} points maximaal {MAX_POINTS} punten +@msg at most {MAX_QUEUE} files maximaal {MAX_QUEUE} bestanden +@msg at must be [x, y] at moet [x, y] zijn +@msg at/rect lie outside the canvas at/rect liggen buiten het canvas +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute moet size|leading|tracking|kerning|baselineShift zijn +@msg attrs must be an object attrs moet een object zijn +@msg automation path rejected: the folder's name isn't valid text automatiseringspad geweigerd: de naam van de map is geen geldige tekst +@msg automation path rejected: {_1} isn't valid text automatiseringspad geweigerd: {_1} is geen geldige tekst +@msg automation path rejected: {why}: {path} automatiseringspad geweigerd: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis moet "vertical", "horizontal" of een hoek in graden zijn, niet `{given}` +@msg backgroundContents must be transparent or white backgroundContents moet transparent of white zijn +@msg bad AES key ongeldige AES-sleutel +@msg bad `runs`: {e} ongeldige `runs`: {e} +@msg bad arrowScale ongeldige arrowScale +@msg bad base64 ongeldige base64 +@msg bad color {c} ongeldige kleur {c} +@msg bad colour ongeldige kleur +@msg bad colour in `colors` ongeldige kleur in `colors` +@msg bad colour {v} ongeldige kleur {v} +@msg bad colour {v} in groupColors ongeldige kleur {v} in groupColors +@msg bad cross-reference entry ongeldig item in de kruisverwijzingstabel +@msg bad cross-reference table ongeldige kruisverwijzingstabel +@msg bad current colour {v} ongeldige huidige kleur {v} +@msg bad dataBase64 ongeldige dataBase64 +@msg bad edit ongeldige bewerking +@msg bad fill {v} ongeldige vulling {v} +@msg bad fit: {e} ongeldige passing: {e} +@msg bad model {other} (keep, new or document) ongeldig model {other} (keep, new of document) +@msg bad new colour {v} ongeldige nieuwe kleur {v} +@msg bad object ongeldig object +@msg bad object number ongeldig objectnummer +@msg bad offset ongeldige positie +@msg bad path data: {e} ongeldige padgegevens: {e} +@msg bad point {v}: expected [x, y] ongeldig punt {v}: verwacht [x, y] +@msg bad point {v}: expected [x, y] or [x, y, pressure] ongeldig punt {v}: verwacht [x, y] of [x, y, pressure] +@msg bad reference ongeldige verwijzing +@msg bad trailer size ongeldige grootte in de trailer +@msg bad {k} colour ongeldige kleur voor {k} +@msg batches cannot nest batches kunnen niet worden genest +@msg bind something to a variable first koppel eerst iets aan een variabele +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed moet 0–{MAX_BLEED} pt zijn: een getal, [top, bottom, left, right] of {{top, bottom, left, right}} +@msg blend must be a mode name blend moet de naam van een modus zijn +@msg both sides of an XPath | must select nodes beide zijden van een XPath-| moeten knooppunten selecteren +@msg brush must be a name or null brush moet een naam of null zijn +@msg by must be a number by moet een getal zijn +@msg can't add page thumbnails: {why} kan geen paginaminiaturen toevoegen: {why} +@msg can't add the editing data for other apps: {why} kan de bewerkingsgegevens voor andere apps niet toevoegen: {why} +@msg can't edit the written PDF: {why} kan de geschreven PDF niet bewerken: {why} +@msg can't encrypt the PDF: {why} kan de PDF niet versleutelen: {why} +@msg can't linearise the PDF: {why} kan de PDF niet lineariseren: {why} +@msg can't open `{_1}`: {_2} kan `{_1}` niet openen: {_2} +@msg can't open `{_1}`: {e} kan `{_1}` niet openen: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) kan `{name}` niet openen: geen indeling die VectorCraft leest (zie document.formats) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) kan `{name}` niet plaatsen: geen indeling die VectorCraft leest (zie document.formats) +@msg can't read back the written PDF: {e} kan de geschreven PDF niet teruglezen: {e} +@msg can't read this Word document: {why} kan dit Word-document niet lezen: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp kan geen .{e}-afbeeldingen schrijven: gebruik .png, .jpg, .gif, .webp, .tif of .bmp +@msg can't write PDF layers, overprint or invisible text: {why} kan geen PDF-lagen, overdruk of onzichtbare tekst schrijven: {why} +@msg can't write the file's copyright: {why} kan het copyright van het bestand niet schrijven: {why} +@msg can't write the file's modification date: {why} kan de wijzigingsdatum van het bestand niet schrijven: {why} +@msg can't write the pages' user units: {why} kan de gebruikerseenheden van de pagina’s niet schrijven: {why} +@msg canceled geannuleerd +@msg cannot convert the object to a primitive value kan het object niet naar een primitieve waarde converteren +@msg cannot convert undefined or null to an object kan undefined of null niet naar een object converteren +@msg cannot distort a zero-size or non-finite bounding box kan een selectiekader zonder grootte of met niet-eindige afmetingen niet vervormen +@msg cannot move an object into itself kan een object niet in zichzelf verplaatsen +@msg case must be upper|lower|title|sentence case moet upper|lower|title|sentence zijn +@msg category `{c}`: one of {_1} categorie `{c}`: een van {_1} +@msg center must be [x, y] center moet [x, y] zijn +@msg character dimensions cannot be represented with the source text transform tekenafmetingen kunnen niet worden weergegeven met de teksttransformatie van de bron +@msg character dimensions cannot be represented with the target text transform tekenafmetingen kunnen niet worden weergegeven met de teksttransformatie van het doel +@msg choose at least one artboard kies minstens één tekengebied +@msg choose at least one status kies minstens één status +@msg clip must be true or false clip moet true of false zijn +@msg clippingMask must be true or false clippingMask moet true of false zijn +@msg closed must be a boolean closed moet een boolean zijn +@msg collapsed must be true or false collapsed moet true of false zijn +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: er is geen RGB- of CMYK-profiel met de naam „{profile}” +@msg colorMode must be rgb or cmyk, not `{m}` colorMode moet rgb of cmyk zijn, niet `{m}` +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel moet {own} (de modus van het document), grayscale of bitmap zijn +@msg colour groups can't go into colour groups kleurgroepen kunnen niet in kleurgroepen worden geplaatst +@msg colour, mode and spot apply to solid-colour swatches only colour, mode en spot gelden alleen voor stalen met een effen kleur +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read kolom {column}: `{cell}` is een `%`-kolom van grafiekgegevensbestanden, die niet worden gelezen +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable kolom {column}: `{cell}` noemt `{name}`, en dat is een variabele van het type {_1} +@msg column {column}: `{cell}` names no variable kolom {column}: `{cell}` noemt geen variabele +@msg column {column}: the variable name is longer than {MAX_NAME} characters kolom {column}: de naam van de variabele is langer dan {MAX_NAME} tekens +@msg column: true floats the whole column and takes no onto, above or below column: true laat de hele kolom zweven en accepteert geen onto, above of below +@msg command must be a shape.* command command moet een shape.*-opdracht zijn +@msg commands[{i}]: missing `command` commands[{i}]: `command` ontbreekt +@msg composer must be "singleLine" or "everyLine" composer moet "singleLine" of "everyLine" zijn +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes samengestelde vormen accepteren paden, samengestelde paden, groepen, samengestelde vormen, tekst, mengsels en omvormingen +@msg compound {_1} must contain exactly one open path to join samengesteld object {_1} moet precies één open pad bevatten om te verbinden +@msg connect needs a path other than the one extended connect heeft een ander pad nodig dan het pad dat wordt verlengd +@msg connect needs id connect heeft id nodig +@msg connect path has no open end het pad van connect heeft geen open uiteinde +@msg continuation must be an array of path chunks continuation moet een array van padstukken zijn +@msg continuation requires points continuation vereist points +@msg copy needs path: the file to copy into the document's folder copy heeft path nodig: het bestand dat naar de map van het document wordt gekopieerd +@msg copy takes images only: relink placed documents where their files are copy accepteert alleen afbeeldingen: koppel geplaatste documenten opnieuw op de plek waar hun bestanden staan +@msg copying a file isn't available on the web een bestand kopiëren is niet beschikbaar op het web +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus moet unknown, copyrighted of publicDomain zijn +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl moet een URL zijn (geen spaties, maximaal 2048 tekens) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners moet uit 4 punten [x, y] bestaan (LB, RB, RO, LO) +@msg counterPer `{c}`: item or file counterPer `{c}`: item of file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: alleen PDF-pagina’s hebben art-, trim-, bleed- en mediakaders; gebruik "crop" of "bounding" +@msg crop rectangle doesn't overlap the image de bijsnijdrechthoek overlapt de afbeelding niet +@msg cross-reference and object streams aren't supported kruisverwijzings- en objectstromen worden niet ondersteund +@msg damaged compressed data: {e} beschadigde gecomprimeerde gegevens: {e} +@msg dash lengths must be numbers, got {v} streeplengten moeten getallen zijn, kreeg {v} +@msg dash must be a list of lengths or null, got {v} dash moet een lijst met lengten of null zijn, kreeg {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) gegevenssleutel `{k}`: letters, cijfers, -, _ en . (niet name of vc-…) +@msg data must be an object of key: value, not {v} data moet een object van sleutel: waarde zijn, niet {v} +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare gegevensset `{name}` bevat een waarde voor `{var}`, die de bibliotheek niet declareert +@msg data set `{name}` holds two values for `{var}` gegevensset `{name}` bevat twee waarden voor `{var}` +@msg data set `{name}` is declared twice gegevensset `{name}` is twee keer gedeclareerd +@msg data takes at most {MAX_DATA} keys at once data accepteert maximaal {MAX_DATA} sleutels tegelijk +@msg dataset `{name}` already exists gegevensset `{name}` bestaat al +@msg dataset `{to}` already exists gegevensset `{to}` bestaat al +@msg date `{d}`: YYYY-MM-DD datum `{d}`: YYYY-MM-DD +@msg decoded data is too large de gedecodeerde gegevens zijn te groot +@msg define a variable first definieer eerst een variabele +@msg detect must be true or false detect moet true of false zijn +@msg direct-select anchor points of a path selecteer met directe selectie ankerpunten van een pad +@msg distance must be > 0 distance moet > 0 zijn +@msg dx and dy give the arrow's direction dx en dy geven de richting van de pijl +@msg dx and dy must be numbers dx en dy moeten getallen zijn +@msg dxf fitTo must be [width, height], positive dxf fitTo moet [width, height] zijn, positief +@msg dxf must be an object of DXF import options dxf moet een object met DXF-importopties zijn +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale moet een positief getal tot en met {_1} zijn, niet {s} +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: een eenheid zoals mm, cm, in of pt +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} elke indeling is een object {format, scale?, suffix?} +@msg each item needs an id elk item heeft een id nodig +@msg each match needs `kind` elke overeenkomst heeft `kind` nodig +@msg each match needs a `name` elke overeenkomst heeft een `name` nodig +@msg each of `fonts` is {family, style?}; 1 to 1000 of them elk item van `fonts` is {family, style?}; 1 tot 1000 stuks +@msg each of `items` is {kind, item} elk item van `items` is {kind, item} +@msg each of `items` is {{id, corners?}} with an object id, not {it} elk item van `items` is {{id, corners?}} met een object-id, niet {it} +@msg each path chunk must contain 1 to 4096 points elk padstuk moet 1 tot 4096 punten bevatten +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) elke keuze is {{at: [x, y]}} of {{rect: [x0, y0, x1, y1]}} (kreeg {v}) +@msg each row needs `from` elke rij heeft `from` nodig +@msg each stop needs a position elke stop heeft een positie nodig +@msg each subpath needs an anchors array elk subpad heeft een array anchors nodig +@msg each weight must be a number of 0 or more (got {v}) elk gewicht moet een getal van 0 of meer zijn (kreeg {v}) +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType moet easeIn, easeOut, easeInOut of easeOutIn zijn +@msg editingData must be true or false editingData moet true of false zijn +@msg editingData must be true, false or "always" editingData moet true, false of "always" zijn +@msg edits overlap bewerkingen overlappen elkaar +@msg effects of objects whose appearances differ can't be moved together effecten van objecten met een verschillende vormgeving kunnen niet samen worden verplaatst +@msg empty header in column {_1} lege koptekst in kolom {_1} +@msg encoding `{_1}`: utf8 or utf16 codering `{_1}`: utf8 of utf16 +@msg ends must be two of "first" and "last" ends moet uit twee waarden "first" en "last" bestaan +@msg every numbered name for the result is taken elke genummerde naam voor het resultaat is al in gebruik +@msg expanded SVG styles exceed the import size limit uitgevouwen SVG-stijlen overschrijden de groottelimiet voor importeren +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes het bestand met exportvoorinstellingen is groter dan {MAX_PRESET_FILE_BYTES} bytes +@msg exportText must be editable or appearance exportText moet editable of appearance zijn +@msg exportType must be movieClip or graphic, not `{s}` exportType moet movieClip of graphic zijn, niet `{s}` +@msg extend needs id extend heeft id nodig +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges bevat maximaal {MAX_FEATURE_CHANGES} tags +@msg featureChanges must be a list of tags featureChanges moet een lijst met tags zijn +@msg features must be a list of tags features moet een lijst met tags zijn +@msg fidelity must be 0..100 fidelity moet 0..100 zijn +@msg files must be {name, dataBase64} files moet {name, dataBase64} zijn +@msg finally table {t} out of range finally-tabel {t} buiten het bereik +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit moet none|autoHeight|shrinkText|block zijn, kreeg {id} +@msg flags cannot be given with a RegExp object flags kunnen niet samen met een RegExp-object worden opgegeven +@msg flattener: {e} afvlakker: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low of een opgeslagen voorinstelling (zie flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile moet along|across zijn, kreeg {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item of itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels mappen dieper dan {MAX_BATCH_DEPTH} niveaus +@msg folders deeper than {MAX_DEPTH} levels mappen dieper dan {MAX_DEPTH} niveaus +@msg font family `{family}` is not available lettertypefamilie `{family}` is niet beschikbaar +@msg font family `{tf}` is not available lettertypefamilie `{tf}` is niet beschikbaar +@msg format `{f}`: pdf or postscript format `{f}`: pdf of postscript +@msg format `{f}`: text or json format `{f}`: text of json +@msg format is `{format}` de indeling is `{format}` +@msg formats must be an array of rows, not {v} formats moet een array van rijen zijn, niet {v} +@msg frequency must be between 1 and 300 lines per inch frequency moet tussen 1 en 300 lijnen per inch liggen +@msg from and to must be finite from en to moeten eindig zijn +@msg from must be [x0, y0, x1, y1] from moet [x0, y0, x1, y1] zijn +@msg function declaration index out of range index van functiedeclaratie buiten het bereik +@msg give `before` or `after`, not both geef `before` of `after`, niet beide +@msg give `char`, `text`, `code` or `unicode` geef `char`, `text`, `code` of `unicode` +@msg give `colors`, or `color` and `rule` geef `colors`, of `color` en `rule` +@msg give `count` or `spacing`, not both geef `count` of `spacing`, niet beide +@msg give `event`, such as onclick geef `event`, zoals onclick +@msg give `file`, the JavaScript file's path or URL geef `file`, het pad of de URL van het JavaScript-bestand +@msg give `fill` and/or `stroke` geef `fill` en/of `stroke` +@msg give `fill`, `stroke` or both geef `fill`, `stroke` of beide +@msg give `folder` geef `folder` +@msg give `fonts`, or open a document geef `fonts`, of open een document +@msg give `from` or `indices`, not both geef `from` of `indices`, niet beide +@msg give `id` (no text is being edited) geef `id` (er wordt geen tekst bewerkt) +@msg give `id` and `anchors`, or `items`, not both geef `id` en `anchors`, of `items`, niet beide +@msg give `id` or `guide`, not both geef `id` of `guide`, niet beide +@msg give `length` or `to` geef `length` of `to` +@msg give `matches`: [{kind, name, target}] geef `matches`: [{kind, name, target}] +@msg give `name` (or edit a pattern) geef `name` (of bewerk een patroon) +@msg give `name` or `names` geef `name` of `names` +@msg give `name`, an SVG filter's name geef `name`, de naam van een SVG-filter +@msg give `path` (a .wasm file) or `dataBase64` (the module) geef `path` (een .wasm-bestand) of `dataBase64` (de module) +@msg give `path`, `data` or `dataBase64` geef `path`, `data` of `dataBase64` +@msg give `path`, `data`, `dataBase64` or `csv` geef `path`, `data`, `dataBase64` of `csv` +@msg give `path`, `xml` or `dataBase64` geef `path`, `xml` of `dataBase64` +@msg give `path`, an SVG file, or `svg`, SVG text geef `path`, een SVG-bestand, of `svg`, SVG-tekst +@msg give `script`, the JavaScript the event runs ("" removes the event) geef `script`, de JavaScript die de gebeurtenis uitvoert ("" verwijdert de gebeurtenis) +@msg give `text` (the reading) or `remove` geef `text` (de lezing) of `remove` +@msg give `x` and `y` (and a `tolerance` of 0 or more) geef `x` en `y` (en een `tolerance` van 0 of meer) +@msg give `xml`, one SVG element geef `xml`, één SVG-element +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments geef een modus of aanpassingen voor r/g/b, c/m/y/k, gray of tint +@msg give a name, or paint the active proxy with a saved preset geef een naam, of verf de actieve proxy met een opgeslagen voorinstelling +@msg give a name, or select a stroke with a saved profile geef een naam, of selecteer een contour met een opgeslagen profiel +@msg give a pattern name or select an object painted with a pattern geef de naam van een patroon of selecteer een object dat met een patroon is geverfd +@msg give at least one of clip, invert, disabled, linked geef minstens een van clip, invert, disabled, linked +@msg give color, none, swatch or gradient geef color, none, swatch of gradient +@msg give dataBase64 geef dataBase64 +@msg give either at (a click) or area (a path drawn around the overlaps) geef at (een klik) of area (een pad dat rond de overlappingen is getekend) +@msg give exactly one of `at`, `rect` and `points` geef precies een van `at`, `rect` en `points` +@msg give exactly one of `path`, `data` and `dataBase64` geef precies een van `path`, `data` en `dataBase64` +@msg give formats or a preset, not both geef formats of een voorinstelling, niet beide +@msg give index or indexes, not both geef index of indexes, niet beide +@msg give newName or delete geef newName of delete +@msg give offset or by geef offset of by +@msg give one element geef één element +@msg give one of `to`, `rect` or `at` geef een van `to`, `rect` of `at` +@msg give one of assets, layers, selection or fullDocument geef een van assets, layers, selection of fullDocument +@msg give only one of onto, above, below, icon and a column place geef slechts een van onto, above, below, icon en een kolomplaats +@msg give opacity or blend geef opacity of blend +@msg give origin: [x, y], or reset: true geef origin: [x, y], of reset: true +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note geef overprintFill, overprintStroke, showCenter, imageMap, url of note +@msg give path (a file) or folder geef path (een bestand) of folder +@msg give path, or name and dataBase64 geef path, of name en dataBase64 +@msg give paths or files geef paths of files +@msg give rows, rowHeight, columns or columnWidth geef rows, rowHeight, columns of columnWidth +@msg give svg, or dataBase64 of an SVG or SVGZ file geef svg, of dataBase64 van een SVG- of SVGZ-bestand +@msg give text geef text +@msg give the asset a name geef de asset een naam +@msg give the preset a `name` geef de voorinstelling een `name` +@msg give the preset a name geef de voorinstelling een naam +@msg give the preset a name (1–64 characters) geef de voorinstelling een naam (1–64 tekens) +@msg give the settings to change, e.g. {"formats"}]} geef de instellingen die moeten worden gewijzigd, bijv. {"formats"}]} +@msg give the style a name geef de stijl een naam +@msg give two or more style `names` geef twee of meer `names` van stijlen +@msg give two or more swatches geef twee of meer stalen +@msg give x, y, or a handle's length or angle geef x, y, of de lengte of hoek van een handgreep +@msg give {key, value} or {values} geef {key, value} of {values} +@msg global must be true or false global moet true of false zijn +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) glyph {_1} wordt niet getekend voor {ch} met de stijl van het teken (zoals hoofdletters, kleinkapitalen of een cijfersysteem) +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} glyph {g} is geen alternatief voor {ch} in {_1} {_2} +@msg gradient must be "objects" or "mesh" gradient moet "objects" of "mesh" zijn +@msg grays must be "profile" or "black", not `{g}` grays moet "profile" of "black" zijn, niet `{g}` +@msg gridColors must be a preset name or two colours gridColors moet de naam van een voorinstelling of twee kleuren zijn +@msg gridSize must be small, medium or large gridSize moet small, medium of large zijn +@msg guide {g} is not selected hulplijn {g} is niet geselecteerd +@msg handle must be 0..3 handle moet 0..3 zijn +@msg handler at {_1} expects {_2} values but its range starts with {d} de handler op {_1} verwacht {_2} waarden, maar het bereik ervan begint met {d} +@msg handler depth too large handlerdiepte te groot +@msg handler range {_1}..{_2} out of range handlerbereik {_1}..{_2} buiten het bereik +@msg hiddenLayers must be true or false hiddenLayers moet true of false zijn +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (de standaard), "omit" of "visible" +@msg highlight a layer to merge into markeer een laag om in samen te voegen +@msg highlight two or more rows in one layer or group markeer twee of meer rijen in één laag of groep +@msg highlight two or more rows to merge markeer twee of meer rijen om samen te voegen +@msg hvcC hvcC +@msg icon docks a panel in the icon column: use window.panel.dock icon dockt een paneel in de pictogramkolom: gebruik window.panel.dock +@msg id must be a dimension id id moet de id van een maatlijn zijn +@msg image has no bounds de afbeelding heeft geen grenzen +@msg image {_1} is linked already afbeelding {_1} is al gekoppeld +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client of server +@msg images in a spot ink whose samples couldn't be read were imported in RGB afbeeldingen in een steunkleurinkt waarvan de samples niet konden worden gelezen, zijn in RGB geïmporteerd +@msg images past the most pixels one file may hold were left out afbeeldingen boven het maximale aantal pixels dat één bestand mag bevatten, zijn weggelaten +@msg images too large to read were left out afbeeldingen die te groot zijn om te lezen, zijn weggelaten +@msg index out of range index buiten het bereik +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes mag maximaal {MAX_GUIDE_INDEXES} items bevatten +@msg indexes must be an array of guide indexes indexes moet een array van hulplijnindexen zijn +@msg instances must be 1..1000 instances moet 1..1000 zijn +@msg instruction out of range instructie buiten het bereik +@msg instruction {pc} ({op}) pops below the stack instructie {pc} ({op}) haalt meer van de stack dan erop staat +@msg instruction {pc} reached with stack depths {seen} and {d} instructie {pc} bereikt met stackdiepten {seen} en {d} +@msg invalid `color` ongeldige `color` +@msg invalid anchor index {v}: expected a non-negative integer ongeldige ankerindex {v}: verwacht een niet-negatief geheel getal +@msg invalid anchor reference {v}: expected [subpath, anchor] ongeldige ankerverwijzing {v}: verwacht [subpath, anchor] +@msg invalid angle ongeldige hoek +@msg invalid array length ongeldige arraylengte +@msg invalid brush: {e} ongeldig penseel: {e} +@msg invalid color ongeldige kleur +@msg invalid colour {c} ongeldige kleur {c} +@msg invalid delta ongeldige delta +@msg invalid group ongeldige groep +@msg invalid object id {v}: expected a non-negative integer ongeldige object-id {v}: verwacht een niet-negatief geheel getal +@msg invalid point ongeldig punt +@msg invalid regular expression flag '{_1}' ongeldige vlag '{_1}' voor reguliere expressie +@msg invalid regular expression: {m} ongeldige reguliere expressie: {m} +@msg invalid size ongeldige grootte +@msg invalid t ongeldige t +@msg invalid time value ongeldige tijdwaarde +@msg invalid tolerance ongeldige tolerantie +@msg is a confirm's answer, whose `value` must be true or false is het antwoord op een confirm, waarvan `value` true of false moet zijn +@msg is a prompt's answer, whose `value` must be a string or null is het antwoord op een prompt, waarvan `value` een string of null moet zijn +@msg is an alert's answer, whose `value` must be "ok" is het antwoord op een alert, waarvan `value` "ok" moet zijn +@msg is longer than 16 KiB is langer dan 16 KiB +@msg it draws its image through a clipping path het tekent zijn afbeelding door een knippad +@msg it draws no image het tekent geen afbeelding +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes het is groter dan {MAX_DICTIONARY_FILE_BYTES} bytes +@msg it isn't a file het is geen bestand +@msg it names {_1} plates, and at most {MAX_PLATES} are read het noemt {_1} platen, en er worden er maximaal {MAX_PLATES} gelezen +@msg its art is too long or too detailed for a head de illustratie ervan is te lang of te gedetailleerd voor een kop +@msg its art paints nothing de illustratie ervan verft niets +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) alle tekengebieden ervan liggen buiten het canvas (meer dan {_1} pt van de oorsprong) +@msg its clipping masks are nested too deeply de knipmaskers ervan zijn te diep genest +@msg its image is too large to read de afbeelding ervan is te groot om te lezen +@msg its metadata has no description de metagegevens ervan hebben geen beschrijving +@msg its metadata has no direct length de metagegevens ervan hebben geen directe lengte +@msg its plates are too large: more than {MAX_SAMPLES} pixels together de platen ervan zijn te groot: samen meer dan {MAX_SAMPLES} pixels +@msg its plates are too large: {_1} plates of {w} × {h} pixels de platen ervan zijn te groot: {_1} platen van {w} × {h} pixels +@msg its plates can't be kept together de platen ervan kunnen niet bij elkaar worden gehouden +@msg its shapes could not be combined into one de vormen ervan konden niet tot één vorm worden gecombineerd +@msg join two different paths verbind twee verschillende paden +@msg jump to {pc}, past the end sprong naar {pc}, voorbij het einde +@msg justify must be auto|left|center|right|justifyAll justify moet auto|left|center|right|justifyAll zijn +@msg kbps must be a positive number, not {kbps} kbps moet een positief getal zijn, niet {kbps} +@msg keywords must be a list of words or a comma-separated string keywords moet een lijst met woorden of een door komma’s gescheiden string zijn +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords mogen maximaal {MAX_KEYWORD} tekens lang zijn, zonder besturingstekens +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind moet 1, 2 of 3 zijn, of geef de `name` van een voorinstelling (zie perspective.presets.list) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind moet graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle of paraStyle zijn +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout moet "on", "off", "neutral" of een boolean zijn, niet {v} +@msg layer `{name}` has no art to export laag `{name}` heeft geen illustratie om te exporteren +@msg layer must name a layer or sublayer layer moet een laag of sublaag noemen +@msg layers can't be intertwined: select the objects in them lagen kunnen niet worden verstrengeld: selecteer de objecten erin +@msg layers go only at the top level or in layers, objects in layers and groups lagen staan alleen op het hoogste niveau of in lagen, objecten in lagen en groepen +@msg layers must be true or false layers moet true of false zijn +@msg layout must be gridByRow, gridByColumn, row or column layout moet gridByRow, gridByColumn, row of column zijn +@msg leading must be a number or "auto" leading moet een getal of "auto" zijn +@msg left/right must be non-negative widths left/right moeten niet-negatieve breedten zijn +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding moet een van {_1} zijn +@msg level `{t}`: 2 or 3 niveau `{t}`: 2 of 3 +@msg line {line}: more than {MAX_CSV_COLS} columns regel {line}: meer dan {MAX_CSV_COLS} kolommen +@msg line {line}: unclosed quote regel {line}: niet-afgesloten aanhalingsteken +@msg line {line}: value for `{var}` must match its variable kind regel {line}: de waarde voor `{var}` moet bij het soort variabele passen +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf of crlf +@msg loading profiles from disk isn't available in the browser profielen van schijf laden is niet beschikbaar in de browser +@msg localize: the first argument must be a localization object or a string localize: het eerste argument moet een lokalisatieobject of een string zijn +@msg margin: a {m} pt margin leaves no imageable area on the paper marge: een marge van {m} pt laat geen afdrukbaar gebied op het papier over +@msg matrix must be finite and invertible, depth finite matrix moet eindig en inverteerbaar zijn, depth eindig +@msg matte: {e} mat: {e} +@msg member is for order front, forward, backward or back member is bedoeld voor order front, forward, backward of back +@msg missing `artboards` `artboards` ontbreekt +@msg missing `at` [x, y] `at` [x, y] ontbreekt +@msg missing `attrs` `attrs` ontbreekt +@msg missing `color` or `opacity` `color` of `opacity` ontbreekt +@msg missing `colors` `colors` ontbreekt +@msg missing `commands` `commands` ontbreekt +@msg missing `destinationFolder` `destinationFolder` ontbreekt +@msg missing `effect` id de id `effect` ontbreekt +@msg missing `family` `family` ontbreekt +@msg missing `find` `find` ontbreekt +@msg missing `gid` (a glyph id, or null for the default glyph) `gid` ontbreekt (een glyph-id, of null voor de standaardglyph) +@msg missing `group` `group` ontbreekt +@msg missing `id` `id` ontbreekt +@msg missing `id`: give one, or select one type object `id` ontbreekt: geef er een, of selecteer één tekstobject +@msg missing `index` (a point index, or null to clear) `index` ontbreekt (een puntindex, of null om te wissen) +@msg missing `index` (a stop index, or null to clear) `index` ontbreekt (een stopindex, of null om te wissen) +@msg missing `index` (an item index or null) `index` ontbreekt (een itemindex of null) +@msg missing `item` `item` ontbreekt +@msg missing `library` `library` ontbreekt +@msg missing `library` (see graphicStyle.libraries) `library` ontbreekt (zie graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) `library` ontbreekt (zie swatch.library.list) +@msg missing `map` (rows [{from, to}] or {current}) `map` ontbreekt (rijen [{from, to}] of {current}) +@msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] ontbreekt +@msg missing `name` (see gradient.presets.list) `name` ontbreekt (zie gradient.presets.list) +@msg missing `offset` (a distance in points) `offset` ontbreekt (een afstand in punten) +@msg missing `path` `path` ontbreekt +@msg missing `path` (a path object id) `path` ontbreekt (de id van een padobject) +@msg missing `picks` `picks` ontbreekt +@msg missing `rule` `rule` ontbreekt +@msg missing `source` id de id `source` ontbreekt +@msg missing `start` (the byte offset of a character) `start` ontbreekt (de bytepositie van een teken) +@msg missing `status`: linked, embedded, missing or modified `status` ontbreekt: linked, embedded, missing of modified +@msg missing `target` id de id `target` ontbreekt +@msg missing `text` `text` ontbreekt +@msg missing `to` `to` ontbreekt +@msg missing `to` (an index) `to` ontbreekt (een index) +@msg missing `to`: a non-negative integer `to` ontbreekt: een niet-negatief geheel getal +@msg missing `type` `type` ontbreekt +@msg missing `value` `value` ontbreekt +@msg missing `word` `word` ontbreekt +@msg missing `{key}` `{key}` ontbreekt +@msg missing `{k}` `{k}` ontbreekt +@msg missing anchor anker ontbreekt +@msg missing anchors ankers ontbreken +@msg missing anchors [[subpath, anchor]…] anchors [[subpath, anchor]…] ontbreekt +@msg missing array `{key}` array `{key}` ontbreekt +@msg missing case case ontbreekt +@msg missing color color ontbreekt +@msg missing commands commands ontbreekt +@msg missing destination folder doelmap ontbreekt +@msg missing from from ontbreekt +@msg missing group group ontbreekt +@msg missing id or ids id of ids ontbreekt +@msg missing ids ids ontbreekt +@msg missing indexes indexes ontbreekt +@msg missing matrix [a,b,c,d,e,f] matrix [a,b,c,d,e,f] ontbreekt +@msg missing name name ontbreekt +@msg missing op op ontbreekt +@msg missing or invalid `color` `color` ontbreekt of is ongeldig +@msg missing or unknown `tool` `tool` ontbreekt of is onbekend +@msg missing point punt ontbreekt +@msg missing point [x,y] punt [x,y] ontbreekt +@msg missing point `{key}` ([x, y]) punt `{key}` ([x, y]) ontbreekt +@msg missing points points ontbreekt +@msg missing points [[x,y]…] points [[x,y]…] ontbreekt +@msg missing segment segment ontbreekt +@msg missing stops stops ontbreekt +@msg missing subpaths subpaths ontbreekt +@msg missing target target ontbreekt +@msg missing to to ontbreekt +@msg missing x, y x, y ontbreekt +@msg mode is set, add or toggle, not `{other}` mode is set, add of toggle, niet `{other}` +@msg mode must be "cmyk" or "rgb" mode moet "cmyk" of "rgb" zijn +@msg mode must be area|onPath, got {o} mode moet area|onPath zijn, kreeg {o} +@msg more than 10000 linked files: package dependencies in smaller parts meer dan 10000 gekoppelde bestanden: verpak de afhankelijkheden in kleinere delen +@msg more than 256 commands meer dan 256 opdrachten +@msg more than {MAX_BATCH_FILES} data sets meer dan {MAX_BATCH_FILES} gegevenssets +@msg more than {MAX_BATCH_FILES} files meer dan {MAX_BATCH_FILES} bestanden +@msg more than {MAX_CSV_ROWS} data rows meer dan {MAX_CSV_ROWS} gegevensrijen +@msg more than {MAX_FILES} images meer dan {MAX_FILES} afbeeldingen +@msg more than {MAX_IMPORT} presets meer dan {MAX_IMPORT} voorinstellingen +@msg moved must be [[x,y]…] moved moet [[x,y]…] zijn +@msg must be an object with `kind` and `value` moet een object met `kind` en `value` zijn +@msg must have `kind` alert, confirm or prompt moet `kind` alert, confirm of prompt hebben +@msg must have exactly `kind` and `value` moet precies `kind` en `value` hebben +@msg name (a toolbar's name) is required name (de naam van een werkbalk) is vereist +@msg name (a toolbar's name) must be a string name (de naam van een werkbalk) moet een string zijn +@msg name must be a toolbar's name name moet de naam van een werkbalk zijn +@msg names is empty names is leeg +@msg names must be an array of symbol names names moet een array van symboolnamen zijn +@msg need `anchor` or `segment` + `t` `anchor` of `segment` + `t` is nodig +@msg need at least one anchor er is minstens één anker nodig +@msg need at least one point er is minstens één punt nodig +@msg need at least two points er zijn minstens twee punten nodig +@msg needs a closing quote after the name heeft een afsluitend aanhalingsteken na de naam nodig +@msg needs a name heeft een naam nodig +@msg needs a name of at most 64 bytes heeft een naam van maximaal 64 bytes nodig +@msg needs a name of letters, digits and _, or a quoted name heeft een naam van letters, cijfers en _ nodig, of een naam tussen aanhalingstekens +@msg needs a name without control characters heeft een naam zonder besturingstekens nodig +@msg needs points heeft punten nodig +@msg nesting too deep te diep genest +@msg nineSlice must be true or false nineSlice moet true of false zijn +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides moet [left, top, right, bottom] zijn, elk -16..16, met left + right < 1 en top + bottom < 1 +@msg no HP-GL format geen HP-GL-indeling +@msg no New Document profile called `{shown}` ({_1}) geen profiel voor nieuwe documenten met de naam `{shown}` ({_1}) +@msg no PDF preset named `{n}` geen PDF-voorinstelling met de naam `{n}` +@msg no SVG filter at index {k} geen SVG-filter op index {k} +@msg no action set is current er is geen actieset actief +@msg no active data set geen actieve gegevensset +@msg no appearance item {index} geen vormgevingsitem {index} +@msg no arrowheads file here hier is geen bestand met pijlpunten +@msg no artboard {_1} geen tekengebied {_1} +@msg no artboard {artboard} (0-based; the document has {_1}) geen tekengebied {artboard} (begint bij 0; het document heeft er {_1}) +@msg no asset {id} geen asset {id} +@msg no asset {id} (see assets.list) geen asset {id} (zie assets.list) +@msg no assets to export geen assets om te exporteren +@msg no assets yet: collect art for export first (assets.add) nog geen assets: verzamel eerst illustraties voor export (assets.add) +@msg no brush named `{n}` geen penseel met de naam `{n}` +@msg no character at byte {start} geen teken op byte {start} +@msg no character has code {code} geen teken heeft code {code} +@msg no colour geen kleur +@msg no colour group `{group}` geen kleurgroep `{group}` +@msg no colour group `{name}` geen kleurgroep `{name}` +@msg no custom arrowhead {name} (see stroke.arrowheads.list) geen aangepaste pijlpunt {name} (zie stroke.arrowheads.list) +@msg no data sets geen gegevenssets +@msg no dataset named `{from}` geen gegevensset met de naam `{from}` +@msg no dataset named `{name}` geen gegevensset met de naam `{name}` +@msg no datasets geen gegevenssets +@msg no design named `{name}` geen ontwerp met de naam `{name}` +@msg no design named `{n}` geen ontwerp met de naam `{n}` +@msg no design named `{old}` geen ontwerp met de naam `{old}` +@msg no encoder for {_1} yet nog geen encoder voor {_1} +@msg no export preset `{name}` (see document.exportPresets.list) geen exportvoorinstelling `{name}` (zie document.exportPresets.list) +@msg no file to open geen bestand om te openen +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder geen map: geef `path` op of stel Voorkeuren › Plug-ins & Scratchschijven › Extra map met plug-ins in +@msg no fonts to look for: they are all available geen lettertypen om te zoeken: ze zijn allemaal beschikbaar +@msg no formats to export geen indelingen om te exporteren +@msg no gradient preset `{name}` (see gradient.presets.list) geen verloopvoorinstelling `{name}` (zie gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` geen grafische stijl `{n}` in `{_1}` +@msg no graphic style library `{key}` (see graphicStyle.libraries) geen bibliotheek met grafische stijlen `{key}` (zie graphicStyle.libraries) +@msg no guide selected geen hulplijn geselecteerd +@msg no guide selected (or give an index) geen hulplijn geselecteerd (of geef een index) +@msg no guide {i} geen hulplijn {i} +@msg no images in the folder geen afbeeldingen in de map +@msg no instructions geen instructies +@msg no item at index {_1} geen item op index {_1} +@msg no item at index {from} geen item op index {from} +@msg no item at one of the indices {idx} geen item op een van de indexen {idx} +@msg no layer geen laag +@msg no layer to rename geen laag om te hernoemen +@msg no layer {id} (see layer.list) geen laag {id} (zie layer.list) +@msg no layers to export geen lagen om te exporteren +@msg no library `{key}` geen bibliotheek `{key}` +@msg no metafile format geen metafile-indeling +@msg no object {_1} geen object {_1} +@msg no objects geen objecten +@msg no parent layer geen bovenliggende laag +@msg no perspective grid preset named `{b}` (see perspective.presets.list) geen voorinstelling voor het perspectiefraster met de naam `{b}` (zie perspective.presets.list) +@msg no perspective grid preset named `{n}` geen voorinstelling voor het perspectiefraster met de naam `{n}` +@msg no point selected (give `index`) geen punt geselecteerd (geef `index`) +@msg no preset called `{from}` (see file.newPresets) geen voorinstelling met de naam `{from}` (zie file.newPresets) +@msg no preset called `{name}` (see file.newPresets) geen voorinstelling met de naam `{name}` (zie file.newPresets) +@msg no preset named `{n}` geen voorinstelling met de naam `{n}` +@msg no print preset named `{b}` (see print.presets.list) geen afdrukvoorinstelling met de naam `{b}` (zie print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) geen afdrukvoorinstelling met de naam `{name}` (voorinstellingen: {_1}) +@msg no print preset named `{n}` geen afdrukvoorinstelling met de naam `{n}` +@msg no profile named `{name}` geen profiel met de naam `{name}` +@msg no random numbers ({e}) geen willekeurige getallen ({e}) +@msg no readable files in the folder geen leesbare bestanden in de map +@msg no recovery copy `{f}` (see file.recovery.list) geen herstelkopie `{f}` (zie file.recovery.list) +@msg no rows geen rijen +@msg no saved PDF preset named `{name}` geen opgeslagen PDF-voorinstelling met de naam `{name}` +@msg no saved export preset `{name}` geen opgeslagen exportvoorinstelling `{name}` +@msg no saved gradient preset called {name} geen opgeslagen verloopvoorinstelling met de naam {name} +@msg no saved gradient presets geen opgeslagen verloopvoorinstellingen +@msg no saved preset called `{name}` geen opgeslagen voorinstelling met de naam `{name}` +@msg no saved presets to export (name [Default] in `names` to export it) geen opgeslagen voorinstellingen om te exporteren (noem [Default] in `names` om die te exporteren) +@msg no saved presets to export (name built-in ones in `names`) geen opgeslagen voorinstellingen om te exporteren (noem ingebouwde in `names`) +@msg no saved print preset named `{name}` geen opgeslagen afdrukvoorinstelling met de naam `{name}` +@msg no saved profile called {name} geen opgeslagen profiel met de naam {name} +@msg no saved selection `{name}` geen opgeslagen selectie `{name}` +@msg no saved theme called {name} (see colorTheme.list) geen opgeslagen thema met de naam {name} (zie colorTheme.list) +@msg no saved views geen opgeslagen weergaven +@msg no selected or given object has a visible 3D effect geen enkel geselecteerd of opgegeven object heeft een zichtbaar 3D-effect +@msg no series {i} geen reeks {i} +@msg no slice {n} in the image (see slice.list) geen slice {n} in de afbeelding (zie slice.list) +@msg no source art geen bronillustratie +@msg no stop {i} (the gradient has {n}) geen stop {i} (het verloop heeft er {n}) +@msg no style `{name}` geen stijl `{name}` +@msg no such artboard {v} tekengebied {v} bestaat niet +@msg no such assets (see assets.list) deze assets bestaan niet (zie assets.list) +@msg no such frame anchor, or the frame would reach past the canvas dit kaderanker bestaat niet, of het kader zou voorbij het canvas reiken +@msg no such objects deze objecten bestaan niet +@msg no such path anchor, or the path would reach past the canvas dit padanker bestaat niet, of het pad zou voorbij het canvas reiken +@msg no such rows deze rijen bestaan niet +@msg no swatch `{keep}` geen staal `{keep}` +@msg no swatch `{n}` geen staal `{n}` +@msg no swatch `{n}` (swatches and colour groups move separately) geen staal `{n}` (stalen en kleurgroepen worden apart verplaatst) +@msg no swatch library `{key}` (see swatch.library.list) geen stalenbibliotheek `{key}` (zie swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) geen stalenbibliotheek `{key}` (zie swatch.library.list; "document": de stalen van het document) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) geen stalenbibliotheek `{key}` (zie swatch.library.list; "{DOCUMENT_SWATCHES}": de stalen van het document) +@msg no swatch named `{n}` geen staal met de naam `{n}` +@msg no swatch or colour group `{n}` geen staal of kleurgroep `{n}` +@msg no swatch or colour group `{n}` in `{_1}` geen staal of kleurgroep `{n}` in `{_1}` +@msg no symbol named `{_1}` geen symbool met de naam `{_1}` +@msg no symbol named `{name}` geen symbool met de naam `{name}` +@msg no text objects selected geen tekstobjecten geselecteerd +@msg no toolbar named `{_1}` geen werkbalk met de naam `{_1}` +@msg no toolbar named `{name}` geen werkbalk met de naam `{name}` +@msg no tracing preset `{preset}` (see imageTrace.presets) geen traceervoorinstelling `{preset}` (zie imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) hier is geen map voor gebruikersbibliotheken (sla op met een pad, of zonder pad om de gegevens te krijgen) +@msg no variable named `{from}` geen variabele met de naam `{from}` +@msg no variable named `{name}` geen variabele met de naam `{name}` +@msg no variable named `{var}` geen variabele met de naam `{var}` +@msg no variable named `{v}` geen variabele met de naam `{v}` +@msg no view named `{name}` geen weergave met de naam `{name}` +@msg node {_1} already has the text variable `{_2}` knooppunt {_1} heeft de tekstvariabele `{_2}` al +@msg node {_1} is not a graph knooppunt {_1} is geen grafiek +@msg node {_1} is not an image knooppunt {_1} is geen afbeelding +@msg node {_1} is not type knooppunt {_1} is geen tekst +@msg not a DCS file geen DCS-bestand +@msg not a PDF presets file (format `{_1}`) geen bestand met PDF-voorinstellingen (indeling `{_1}`) +@msg not a PDF presets file: {e} geen bestand met PDF-voorinstellingen: {e} +@msg not a RegExp object geen RegExp-object +@msg not a Word document: its WordDocument stream doesn't start with a file information block geen Word-document: de WordDocument-stroom ervan begint niet met een blok met bestandsinformatie +@msg not a Word document: its compound file has no WordDocument stream geen Word-document: het samengestelde bestand ervan heeft geen WordDocument-stroom +@msg not a Word document: its main part is not a WordprocessingML document geen Word-document: het hoofdonderdeel ervan is geen WordprocessingML-document +@msg not a Word document: its package has no main document part geen Word-document: het pakket ervan heeft geen hoofddocumentonderdeel +@msg not a closed path geen gesloten pad +@msg not a flattener presets file (format `{_1}`) geen bestand met afvlakkervoorinstellingen (indeling `{_1}`) +@msg not a flattener presets file: {e} geen bestand met afvlakkervoorinstellingen: {e} +@msg not a layer or group geen laag of groep +@msg not a library file: {e} geen bibliotheekbestand: {e} +@msg not a mesh envelope point geen punt van een netomvorming +@msg not a perspective grid presets file (format `{_1}`) geen bestand met voorinstellingen voor het perspectiefraster (indeling `{_1}`) +@msg not a perspective grid presets file: {e} geen bestand met voorinstellingen voor het perspectiefraster: {e} +@msg not a print presets file (format `{_1}`) geen bestand met afdrukvoorinstellingen (indeling `{_1}`) +@msg not a print presets file: {e} geen bestand met afdrukvoorinstellingen: {e} +@msg not a regular expression geen reguliere expressie +@msg not a stored or deflated central record geen opgeslagen of met deflate gecomprimeerd centraal record +@msg not a valid SVGZ file: {e} geen geldig SVGZ-bestand: {e} +@msg not an RTF file: it doesn't start with {\\rtf geen RTF-bestand: het begint niet met {\\rtf +@msg not an action set: the text doesn't start with /version and a number geen actieset: de tekst begint niet met /version en een getal +@msg not an image VectorCraft reads (see document.formats) geen afbeelding die VectorCraft leest (zie document.formats) +@msg not area type (text in a frame) outside perspective geen vlaktekst (tekst in een kader) buiten perspectief +@msg not editing an opacity mask er wordt geen dekkingmasker bewerkt +@msg not type on a path outside perspective geen tekst op een pad buiten perspectief +@msg nothing prints er wordt niets afgedrukt +@msg nothing to bind niets om te koppelen +@msg nothing to change niets om te wijzigen +@msg nothing to change: give dx, dy, hScale, vScale or rotation niets om te wijzigen: geef dx, dy, hScale, vScale of rotation +@msg nothing to crop niets om bij te snijden +@msg nothing to export niets om te exporteren +@msg nothing to export: the document has no visible art niets om te exporteren: het document heeft geen zichtbare illustraties +@msg nothing to mark niets om te markeren +@msg nothing to print: every artboard picked is blank niets om af te drukken: elk gekozen tekengebied is leeg +@msg nothing to print: the document has no art that prints niets om af te drukken: het document heeft geen illustraties die worden afgedrukt +@msg nothing to repeat niets om te herhalen +@msg numbers out of order in {_1} quantifier getallen in de verkeerde volgorde in kwantor {_1} +@msg object is not a path het object is geen pad +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object objecttransparantie kan niet op gemarkeerde tekens worden overgenomen; schakel Transparantie uit in Opties voor pipet of selecteer het hele object +@msg object {_1} can't be set as point type object {_1} kan niet als punttekst worden gezet +@msg object {_1} has lost its file's contents: relink it object {_1} is de inhoud van zijn bestand kwijt: koppel het opnieuw +@msg object {_1} has no path object {_1} heeft geen pad +@msg object {_1} has no size object {_1} heeft geen grootte +@msg object {_1} is hidden or locked, or on a locked artboard object {_1} is verborgen of vergrendeld, of staat op een vergrendeld tekengebied +@msg object {_1} is not a placed document object {_1} is geen geplaatst document +@msg object {_1} is not an Intertwine group object {_1} is geen groep Verstrengelen +@msg object {_1} is not an image object {_1} is geen afbeelding +@msg object {_1} is not an image or a linked file object {_1} is geen afbeelding of gekoppeld bestand +@msg object {_1} is not an image or a placed document object {_1} is geen afbeelding of geplaatst document +@msg object {_1} is not in this Intertwine group object {_1} zit niet in deze groep Verstrengelen +@msg object {_1} needs a 3D effect object {_1} heeft een 3D-effect nodig +@msg object {_1} needs a 3D effect at index {index} object {_1} heeft een 3D-effect op index {index} nodig +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic object {_1}: een holle Extruderen en afschuinen heeft geen kappen: schakel Kap in om een afbeelding toe te wijzen +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front object {_1}: met een diepte van 0 heeft het object één vlak: wijs de afbeeldingen ervan toe aan de voorkant +@msg object {_1}: {_2} object {_1}: {_2} +@msg object {_1}: {e} object {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams object {_1}: {name} op index {index} heeft geen belichtingsvoorinstellingen; stel de lichten ervan in met effect.setParams +@msg object {id} has anchors 0–{_1} for `corners`, not {past} object {id} heeft de ankers 0–{_1} voor `corners`, niet {past} +@msg object {id} has {n} anchors, so no anchor {past} object {id} heeft {n} ankers, dus geen anker {past} +@msg objects must be none, move or copy objects moet none, move of copy zijn +@msg objects were left out er zijn objecten weggelaten +@msg offset out of range positie buiten het bereik +@msg on must be true or false on moet true of false zijn +@msg only groups, layers, type and images have a Contents (Characters) row alleen groepen, lagen, tekst en afbeeldingen hebben een rij Inhoud (Tekens) +@msg only interior mesh lines can be deleted alleen binnenste netlijnen kunnen worden verwijderd +@msg only solid colours can be added (not gradients or patterns) alleen effen kleuren kunnen worden toegevoegd (geen verlopen of patronen) +@msg only solid-colour swatches can be spot colours alleen stalen met een effen kleur kunnen steunkleuren zijn +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first alleen de ingesloten voorvertoning van dit Affinity-bestand kon worden gelezen: gebruik Bestand › Openen om die met de bijbehorende waarschuwing te bekijken, of exporteer eerst SVG of PDF vanuit Affinity +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning alleen de ingesloten voorvertoning van dit Affinity-bestand kon worden gelezen; gebruik Bestand › Openen om die met de bijbehorende waarschuwing te bekijken +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon en een kolomplaats horen bij window.panel.float en window.panel.dock +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon en een kolomplaats accepteren geen anchor, side, zone of before +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon en een kolomplaats accepteren geen x, y, width of height +@msg opacity must be a number opacity moet een getal zijn +@msg open a document to look for its linked files open een document om de gekoppelde bestanden ervan te zoeken +@msg operand too large operand te groot +@msg options must be an object (see file.formatOptions) options moet een object zijn (zie file.formatOptions) +@msg order is a whole number from 0 to {others}, not `{v}` order is een geheel getal van 0 tot {others}, niet `{v}` +@msg order must be leftToRight or rightToLeft order moet leftToRight of rightToLeft zijn +@msg orientation must be portrait or landscape, not `{o}` orientation moet portrait of landscape zijn, niet `{o}` +@msg outlineText {v}: true or false outlineText {v}: true of false +@msg output intent profile: {e} profiel van de uitvoerintentie: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: er is geen RGB- of CMYK-profiel met de naam „{name}” +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1}-bestanden zijn CMYK, en „{name}” is een RGB-profiel +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: er is geen inkt ingesteld om af te drukken +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: er is geen profiel met de naam „{name}”, en een uitvoerintentie voor {std} sluit haar profiel in +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: er is geen profiel met de naam „{name}”: een uitvoerintentie voor {std} sluit een profiel in, of noemt een geregistreerde drukconditie (outputConditionId en registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: {std}-bestanden worden in CMYK afgedrukt, en „{name}” is een RGB-profiel +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: „{name}” is een grijsprofiel, dat geen uitvoerintentie voor {std} kan zijn +@msg overlap: {o} pt is half a tile or more overlap: {o} pt is een halve tegel of meer +@msg overlapping Shaper paths must be in the same layer or group overlappende Shaper-paden moeten in dezelfde laag of groep staan +@msg overlapping edits overlappende bewerkingen +@msg page art conflicts with image data for {key} de pagina-illustratie is in strijd met de afbeeldingsgegevens voor {key} +@msg page art conflicts with pattern {name} de pagina-illustratie is in strijd met patroon {name} +@msg page art conflicts with swatch {name} de pagina-illustratie is in strijd met staal {name} +@msg page art is missing image data for {key} in de pagina-illustratie ontbreken afbeeldingsgegevens voor {key} +@msg page art is missing pattern {name} in de pagina-illustratie ontbreekt patroon {name} +@msg page art is missing swatch {name} in de pagina-illustratie ontbreekt staal {name} +@msg page must be a whole number from 1 page moet een geheel getal vanaf 1 zijn +@msg page thumbnail: {e} paginaminiatuur: {e} +@msg page {page} of the PDF has no objects pagina {page} van de PDF heeft geen objecten +@msg page {page}: `{file}` has {n} {artboards} pagina {page}: `{file}` heeft {n} {artboards} +@msg page {page}: `{name}` has {n} {pages} pagina {page}: `{name}` heeft {n} {pages} +@msg pages must be a range such as "2-3" or a page number, not {v} pages moet een bereik zoals "2-3" of een paginanummer zijn, niet {v} +@msg pages: {e} pagina’s: {e} +@msg panel position must be finite de paneelpositie moet eindig zijn +@msg parameters must be an object de parameters moeten een object zijn +@msg params must be an object params moet een object zijn +@msg passwords can have up to {MAX_PASSWORD} bytes wachtwoorden mogen maximaal {MAX_PASSWORD} bytes hebben +@msg path chunks must share their boundary point padstukken moeten hun grenspunt delen +@msg path has no open end het pad heeft geen open uiteinde +@msg paths must be strings paths moet uit strings bestaan +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false vereist dat elk tekengebied de bewerkbare illustratie behoudt +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false vereist dat de tekengebieden de bewerkbare illustratie behouden wanneer useArtboards false is +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single of multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom is "artwork" of "swatches", niet {v} +@msg pins and moved must be non-empty lists of the same length pins en moved moeten niet-lege lijsten van dezelfde lengte zijn +@msg pins must be [[x,y]…] pins moet [[x,y]…] zijn +@msg pins must be finite pins moet eindig zijn +@msg place is above, below or inside, not `{other}` place is above, below of inside, niet `{other}` +@msg plane must be left|right|ground plane moet left|right|ground zijn +@msg plane must be left|right|ground|none plane moet left|right|ground|none zijn +@msg point is outside the mesh het punt ligt buiten het net +@msg point x must be a number x van het punt moet een getal zijn +@msg point y must be a number y van het punt moet een getal zijn +@msg points must be [t, left, right] with non-negative widths points moet uit [t, left, right] met niet-negatieve breedten bestaan +@msg points must be [x,y] pairs points moet uit paren [x,y] bestaan +@msg points must be a non-empty [[x,y,pressure?]…] list points moet een niet-lege lijst [[x,y,pressure?]…] zijn +@msg points must be a non-empty list of finite [x,y] points moet een niet-lege lijst van eindige [x,y] zijn +@msg points must be an array or null points moet een array of null zijn +@msg points must be finite points moet eindig zijn +@msg points must be finite [x,y] pairs points moet uit eindige paren [x,y] bestaan +@msg points must be finite canvas points points moet uit eindige canvaspunten bestaan +@msg points must contain 1–4096 coordinates points moet 1–4096 coördinaten bevatten +@msg position must be normal, superscript or subscript position moet normal, superscript of subscript zijn +@msg position must be topLeft, topRight, bottomLeft or bottomRight position moet topLeft, topRight, bottomLeft of bottomRight zijn +@msg positions must be between 0 and 100000 pt positions moeten tussen 0 en 100000 pt liggen +@msg ppi must be a positive number, not {ppi} ppi moet een positief getal zijn, niet {ppi} +@msg ppi must be between 1 and 2400 ppi moet tussen 1 en 2400 liggen +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` is niet transforms|bounds|fileDimensions|fit|fill +@msg preserveShape must be clippingMask|transparency preserveShape moet clippingMask|transparency zijn +@msg preset `{id}`: mobile or density preset `{id}`: mobile of density +@msg preset must be standard, diffuse, topLeft or right preset moet standard, diffuse, topLeft of right zijn +@msg preset must be the name of a print preset preset moet de naam van een afdrukvoorinstelling zijn +@msg previewMode must be default, pixel or overprint previewMode moet default, pixel of overprint zijn +@msg print settings: {e} afdrukinstellingen: {e} +@msg procedures nest too deeply procedures zijn te diep genest +@msg procedures nested too deeply procedures te diep genest +@msg quotes must be {double?, single?} quotes moet {double?, single?} zijn +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} moet een openend en een sluitend aanhalingsteken zijn, bijv. "“”" +@msg range out of order in character class bereik in de verkeerde volgorde in tekenklasse +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi moet 1–2400 zijn (72 scherm, 150 gemiddeld, 300 hoog) +@msg rating must be a whole number from 0 to {MAX_RATING} rating moet een geheel getal van 0 tot {MAX_RATING} zijn +@msg rect and {k} contradict each other: give the area or the artboards rect en {k} spreken elkaar tegen: geef het gebied of de tekengebieden +@msg rect applies to raster formats, not {_1} rect geldt voor rasterindelingen, niet voor {_1} +@msg rect is [x, y, width, height] in points rect is [x, y, width, height] in punten +@msg rect must be [x, y, width, height] rect moet [x, y, width, height] zijn +@msg rect must be [x, y, width, height] with a positive width and height rect moet [x, y, width, height] met een positieve breedte en hoogte zijn +@msg rect needs finite numbers and a positive width and height rect heeft eindige getallen en een positieve breedte en hoogte nodig +@msg redraw needs id redraw heeft id nodig +@msg redraw within must be a finite distance of 0 or more within van redraw moet een eindige afstand van 0 of meer zijn +@msg reduce of an empty array with no initial value reduce van een lege array zonder beginwaarde +@msg registration must be 0..8 registration moet 0..8 zijn +@msg regular expression flag '{_1}' given twice vlag '{_1}' voor reguliere expressie twee keer opgegeven +@msg release a layer or a group geef een laag of een groep vrij +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace behoudt de plaats en de transformatie van het vervangen object: laat template, at en rect weg +@msg replace: select the one object to replace replace: selecteer het ene object dat moet worden vervangen +@msg resolution must be a number or screen|medium|high resolution moet een getal of screen|medium|high zijn +@msg resolution must be between 1 and 2400 ppi resolution moet tussen 1 en 2400 ppi liggen +@msg rotation must be a number of degrees rotation moet een aantal graden zijn +@msg rotation must be finite rotation moet eindig zijn +@msg rows and cols must be 1..50 rows en cols moeten 1..50 zijn +@msg rows and columns must be 1..500 rows en columns moeten 1..500 zijn +@msg rows of objects whose appearances differ can't be reordered together rijen van objecten met een verschillende vormgeving kunnen niet samen opnieuw worden geordend +@msg sampled text would exceed the canvas (coordinates out of range) de overgenomen tekst zou buiten het canvas vallen (coördinaten buiten het bereik) +@msg save the document first: Package collects a saved document sla het document eerst op: Verpakken verzamelt een opgeslagen document +@msg save the document first: copy puts the file in its folder sla het document eerst op: copy plaatst het bestand in de map ervan +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: een factor zoals 2x, een breedte (100w), een hoogte (100h) of een resolutie (72ppi) +@msg scale must be a number above 0, at most 64 scale moet een getal boven 0 zijn, maximaal 64 +@msg scale must be non-zero scale mag niet nul zijn +@msg scope must be selection or all (got {other}) scope moet selection of all zijn (kreeg {other}) +@msg scribble is too long de krabbel is te lang +@msg segment does not exist het segment bestaat niet +@msg select a Shaper group selecteer een Shaper-groep +@msg select a blend to set easing or color shift selecteer een mengsel om de versnelling of de kleurverschuiving in te stellen +@msg select a compound shape selecteer een samengestelde vorm +@msg select a graph selecteer een grafiek +@msg select a group or layer with visible, unlocked members selecteer een groep of laag met zichtbare, ontgrendelde leden +@msg select a layer selecteer een laag +@msg select a text object selecteer een tekstobject +@msg select an SVG filter selecteer een SVG-filter +@msg select an image (or an Image Trace group) selecteer een afbeelding (of een groep Afbeelding traceren) +@msg select an image, or pass id selecteer een afbeelding, of geef id op +@msg select an object (or pass `id`) selecteer een object (of geef `id` op) +@msg select an object with a 3D effect selecteer een object met een 3D-effect +@msg select an object with a 3D effect, or give its id in ids selecteer een object met een 3D-effect, of geef de id ervan in ids +@msg select an object with a freeform gradient selecteer een object met een verloop in vrije vorm +@msg select an object with an opacity mask selecteer een object met een dekkingmasker +@msg select area type selecteer vlaktekst +@msg select area type (text in a frame) selecteer vlaktekst (tekst in een kader) +@msg select area type and closed paths (at least one area type object) selecteer vlaktekst en gesloten paden (minstens één vlaktekstobject) +@msg select area type whose first line can fit its frame selecteer vlaktekst waarvan de eerste regel in het kader past +@msg select artwork to add its colours selecteer illustraties om de kleuren ervan toe te voegen +@msg select artwork to make a group of its colours selecteer illustraties om een groep van de kleuren ervan te maken +@msg select artwork to recolour, or give a colour `group` selecteer illustraties om te herkleuren, of geef een `group` met kleuren +@msg select exactly two path ends to join selecteer precies twee paduiteinden om te verbinden +@msg select members of a compound shape selecteer leden van een samengestelde vorm +@msg select objects or give ids selecteer objecten of geef ids +@msg select objects to align to the guide selecteer objecten om op de hulplijn uit te lijnen +@msg select one Intertwine group selecteer één groep Verstrengelen +@msg select one image or pass id selecteer één afbeelding of geef id op +@msg select one layer, group or object to rename selecteer één laag, groep of object om te hernoemen +@msg select paths selecteer paden +@msg select paths or compound paths selecteer paden of samengestelde paden +@msg select rows or objects to collect selecteer rijen of objecten om te verzamelen +@msg select something to export selecteer iets om te exporteren +@msg select something to export (or turn off selectedOnly) selecteer iets om te exporteren (of schakel selectedOnly uit) +@msg select text to redefine the style from selecteer tekst om de stijl mee opnieuw te definiëren +@msg select the art and, on top of it, the mask object selecteer de illustratie en, daarbovenop, het maskerobject +@msg select the art for the design selecteer de illustratie voor het ontwerp +@msg select the art for the pattern tile selecteer de illustratie voor de patroontegel +@msg select the art to add first selecteer eerst de illustratie die moet worden toegevoegd +@msg select the art to collect (or give its ids) selecteer de illustratie die moet worden verzameld (of geef de id’s ervan) +@msg select the art to weed selecteer de illustratie die moet worden gewied +@msg select the banner, or mark every artboard selecteer de banner, of markeer elk tekengebied +@msg select the content and a path on top selecteer de inhoud en een pad erbovenop +@msg select the images to relink, or pass ids selecteer de afbeeldingen die opnieuw moeten worden gekoppeld, of geef ids op +@msg select the images, or pass ids selecteer de afbeeldingen, of geef ids op +@msg select the linked images or placed documents, or pass ids selecteer de gekoppelde afbeeldingen of geplaatste documenten, of geef ids op +@msg select the object to copy from selecteer het object waarvan moet worden gekopieerd +@msg select the objects (or give their ids) selecteer de objecten (of geef de id’s ervan) +@msg select the objects to attach to the path selecteer de objecten die aan het pad moeten worden gekoppeld +@msg select the objects to bind selecteer de objecten die moeten worden gekoppeld +@msg select two or more objects to intertwine selecteer twee of meer objecten om te verstrengelen +@msg select two or more slices to combine selecteer twee of meer slices om te combineren +@msg select type on a path selecteer tekst op een pad +@msg select type on a path or area type selecteer tekst op een pad of vlaktekst +@msg selection `{m}`: one or each selection `{m}`: one of each +@msg settings is an object of document.exportForScreens params settings is een object met parameters van document.exportForScreens +@msg settings must be an object settings moet een object zijn +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified of embedded +@msg show must be true or false show moet true of false zijn +@msg side is left or right, not `{v}` side is left of right, niet `{v}` +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size moet "small", "medium", "large" of een aantal punten boven 0 tot en met {_1} zijn +@msg size must be positive size moet positief zijn +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode is "fixed" of "pressure", niet `{other}` +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode is "fixed" of "pressure", niet {other} +@msg sizeVariation must be 0 or more sizeVariation moet 0 of meer zijn +@msg smallCaps must be true or false smallCaps moet true of false zijn +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind of status +@msg source paths are too large for Shaper de bronpaden zijn te groot voor Shaper +@msg source text transform is collapsed or unrepresentable de teksttransformatie van de bron is samengevallen of kan niet worden weergegeven +@msg spot, mode and group apply to solid colours only spot, mode en group gelden alleen voor effen kleuren +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing of modified +@msg steps must be 1..1000 steps moet 1..1000 zijn +@msg steps must be 1..{MAX_STEPS} steps moet 1..{MAX_STEPS} zijn +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) stop {i}: `plates` moet uit vier getallen van 0 tot 1 bestaan (C, M, Y, K) +@msg stopped before the archive was finished gestopt voordat het archief klaar was +@msg strokeMode must be within|along|across, got {m} strokeMode moet within|along|across zijn, kreeg {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions moet een object met opties van stroke.set zijn +@msg strokeWidth must be a positive number of pixels strokeWidth moet een positief aantal pixels zijn +@msg subpath {si} is not an open subpath subpad {si} is geen open subpad +@msg subpaths required when joining the same path subpaths is vereist bij het verbinden van hetzelfde pad +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict moet "merge" of "add" zijn, niet {v} +@msg table must be true or false table moet true of false zijn +@msg takes one name accepteert één naam +@msg target text transform is collapsed or unrepresentable de teksttransformatie van het doel is samengevallen of kan niet worden weergegeven +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text moet {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} zijn +@msg text transform is collapsed or unrepresentable de teksttransformatie is samengevallen of kan niet worden weergegeven +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces moet een geheel getal van 2 tot 100 zijn +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: gebruik "{_1}" of "{_2}" +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: geef {path} of {name, dataBase64} van een afbeeldingsbestand, of een textuur zoals effect.list die geeft +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices dat levert meer dan {MAX_PIECES} slices op +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer dat levert te veel stukken (maximaal 100000) of streepstappen op: vereenvoudig het streeppatroon of maak de strepen langer +@msg that object is not in the overlap dat object ligt niet in de overlapping +@msg the Affinity document has no page with a size het Affinity-document heeft geen pagina met een grootte +@msg the CMYK image couldn't be written de CMYK-afbeelding kon niet worden geschreven +@msg the Layers panel is not shown het paneel Lagen wordt niet getoond +@msg the Layers panel's search, filter or Show Layers Only hides its row de zoekopdracht, het filter of Alleen lagen weergeven van het paneel Lagen verbergt de rij ervan +@msg the Objects on Path group is hidden or locked de groep Objecten op pad is verborgen of vergrendeld +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected de PDF-beveiligingsinstellingen zijn zonder hun wachtwoorden opgeslagen: geef openPassword of permissionsPassword opnieuw, of security zonder passwordRequired om onbeveiligd te exporteren +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) het RTF-bestand heeft te veel alinea’s of opmaakwijzigingen om als tekst te plaatsen (maximaal {MAX_PARAGRAPHS} alinea’s en {MAX_RUNS} runs) +@msg the RTF file is too large to place as type (at most {_1} MB) het RTF-bestand is te groot om als tekst te plaatsen (maximaal {_1} MB) +@msg the SVG expands to more than {0} nested SVG images de SVG wordt uitgevouwen tot meer dan {0} geneste SVG-afbeeldingen +@msg the SVG expands to more than {0} objects de SVG wordt uitgevouwen tot meer dan {0} objecten +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes het SVG-bestand is groter dan {MAX_IMPORT_BYTES} bytes +@msg the SVG has no drawable objects de SVG heeft geen tekenbare objecten +@msg the SVGZ file unpacks to more than {_1} MB het SVGZ-bestand wordt uitgepakt tot meer dan {_1} MB +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place het Word-document heeft meer dan {MAX_PAGES} pagina’s met eigenschappen, te veel om te plaatsen +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place het Word-document heeft meer dan {MAX_RUNS} runs met teken- of alinea-eigenschappen, te veel om te plaatsen +@msg the Word document has more than {_1} MB of formatting properties, too many to place het Word-document heeft meer dan {_1} MB aan opmaakeigenschappen, te veel om te plaatsen +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) het Word-document heeft te veel tekst of opmaak om als tekst te plaatsen (maximaal {MAX_PARAGRAPHS} alinea’s, {MAX_RUNS} runs en {_1} MB tekst) +@msg the Word document has too much text to place as type (at most {_1} MB) het Word-document heeft te veel tekst om als tekst te plaatsen (maximaal {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it het Word-document is versleuteld of met een wachtwoord beveiligd; sla het zonder wachtwoord op om het te plaatsen +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) het Word-document is te groot om te plaatsen (de stroom {name} ervan bevat meer dan {_1} MB) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off het Word-document is het laatst opgeslagen met snel opslaan, wat VectorCraft niet leest; sla het opnieuw op met snel opslaan uitgeschakeld +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document het Word-document is opgeslagen door Word 6.0 of Word 95, wat VectorCraft niet leest; sla het op als een Word 97-2003-document +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place de tekst van het Word-document bestaat uit meer dan {MAX_PIECES} stukken, te veel om te plaatsen +@msg the XML operation needs an XML or XMLList value de XML-bewerking heeft een XML- of XMLList-waarde nodig +@msg the XPath expression does not evaluate to a node-set de XPath-expressie levert geen knooppuntenset op +@msg the XPath expression ends early de XPath-expressie eindigt te vroeg +@msg the XPath expression has an unmatched ) de XPath-expressie heeft een ) zonder tegenhanger +@msg the XPath expression has an unmatched ] de XPath-expressie heeft een ] zonder tegenhanger +@msg the XPath expression has an unmatched closing bracket de XPath-expressie heeft een sluithaakje zonder tegenhanger +@msg the XPath expression selects a namespace node, which is not an XML value de XPath-expressie selecteert een naamruimteknooppunt, en dat is geen XML-waarde +@msg the XPath expression selects the root node, which is not an XML value de XPath-expressie selecteert het hoofdknooppunt, en dat is geen XML-waarde +@msg the XPath function {name} needs a node-set de XPath-functie {name} heeft een knooppuntenset nodig +@msg the XPath function {name} takes the wrong number of arguments de XPath-functie {name} krijgt het verkeerde aantal argumenten +@msg the XPath name functions need a node-set de XPath-naamfuncties hebben een knooppuntenset nodig +@msg the XPath prefix {shown} is not bound to a namespace het XPath-voorvoegsel {shown} is niet aan een naamruimte gebonden +@msg the XPath variable ${shown} is not bound de XPath-variabele ${shown} is niet gebonden +@msg the action set ids are not a list de id’s van de actieset zijn geen lijst +@msg the active paint is not a gradient de actieve verf is geen verloop +@msg the argument list is not an object de argumentenlijst is geen object +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it de illustratie van een grafiekontwerp in een grafiekreeks wordt opnieuw vanuit het ontwerp getekend: bewerk het ontwerp om het te wijzigen +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size de tekengebieden passen niet op het canvas: minder tekengebieden, minder tussenruimte of een kleinere grootte +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out de illustratie is te groot voor EPS: wat na 256 MB PostScript komt, is weggelaten +@msg the banner has no width or height de banner heeft geen breedte of hoogte +@msg the bitmaps hold more pixels than one file may de bitmaps bevatten meer pixels dan één bestand mag bevatten +@msg the character's size can't be measured de grootte van het teken kan niet worden gemeten +@msg the code runs past its last instruction de code loopt voorbij de laatste instructie +@msg the compound shape is too complex to expand de samengestelde vorm is te complex om uit te breiden +@msg the container of the listed objects is gone de container van de vermelde objecten is verdwenen +@msg the copy `{user_id}` can't be read as a library de kopie `{user_id}` kan niet als bibliotheek worden gelezen +@msg the copy would leave the canvas de kopie zou buiten het canvas vallen +@msg the curve tolerance must be from 0.01 to 10 points de curvetolerantie moet van 0,01 tot 10 punten zijn +@msg the custom dictionary couldn't be saved to {_1}: {e} het aangepaste woordenboek kon niet worden opgeslagen in {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is het aangepaste woordenboek {_1} kan niet worden gelezen ({why}), dus blijft het zoals het is +@msg the cut cannot be made on this art: {e} de snede kan niet in deze illustratie worden gemaakt: {e} +@msg the data isn't an EMF or WMF picture de gegevens zijn geen EMF- of WMF-afbeelding +@msg the data isn't an EPS or PostScript picture de gegevens zijn geen EPS- of PostScript-afbeelding +@msg the deferred paste is no longer available het uitgestelde plakken is niet meer beschikbaar +@msg the design is too big to draw at every data point of this graph het ontwerp is te groot om bij elk gegevenspunt van deze grafiek te tekenen +@msg the design is too big to draw in every column of this graph het ontwerp is te groot om in elke kolom van deze grafiek te tekenen +@msg the dialog it was opened over is no longer open het dialoogvenster waarboven het is geopend, is niet meer geopend +@msg the dictionary stack overflowed de woordenboekstack is overgelopen +@msg the document already has an SVG filter named {name} het document heeft al een SVG-filter met de naam {name} +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it het document heeft een ander SVG-filter met de naam {name}: gebruik svgFilter.apply, of svgFilter.define met `replace` om het te bewerken +@msg the document has no SVG filter named {name} het document heeft geen SVG-filter met de naam {name} +@msg the document has no SVG filter named {r} het document heeft geen SVG-filter met de naam {r} +@msg the document has no color swatches het document heeft geen kleurstalen +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 de stalen van het document bevatten {_1} kleuren; de documentreductie accepteert er maximaal 255 +@msg the editing data's hash has no place de hash van de bewerkingsgegevens heeft geen plaats +@msg the entities the document declares expand to too much text de entiteiten die het document declareert, worden tot te veel tekst uitgevouwen +@msg the envelope's content has no bounds de inhoud van de omvorming heeft geen grenzen +@msg the envelope's mesh is malformed het net van de omvorming is misvormd +@msg the file holds no `variableSets` element het bestand bevat geen element `variableSets` +@msg the file is larger than {_1} MB het bestand is groter dan {_1} MB +@msg the file is too large het bestand is te groot +@msg the file isn't valid SVG: {e} het bestand is geen geldige SVG: {e} +@msg the file names no plate het bestand noemt geen plaat +@msg the file was updated or encrypted already het bestand is al bijgewerkt of versleuteld +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) het eerste teken van een regel heeft geen teken ervoor om mee te kernen: het beweegt alleen omhoog en omlaag (dy) +@msg the font {_1} is not available het lettertype {_1} is niet beschikbaar +@msg the format has no file extension de indeling heeft geen bestandsextensie +@msg the function's prototype is not an object het prototype van de functie is geen object +@msg the graphic can't be read: {e} de afbeelding kan niet worden gelezen: {e} +@msg the graphic is empty de afbeelding is leeg +@msg the graphic's data is damaged de gegevens van de afbeelding zijn beschadigd +@msg the graphic's document has no artboard het document van de afbeelding heeft geen tekengebied +@msg the grommet inset is too large for the banner de afstand van de zeilringen tot de rand is te groot voor de banner +@msg the guide is hidden or locked de hulplijn is verborgen of vergrendeld +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them de koptekst `{cell}` voor `{name}` zou worden teruggelezen als de variabele `{other}`: geef een van beide een andere naam +@msg the hem is too deep for the banner de zoom is te diep voor de banner +@msg the image `{path}` isn't followed: {r} de afbeelding `{path}` wordt niet gevolgd: {r} +@msg the image has no embedded pixels de afbeelding heeft geen ingesloten pixels +@msg the image is empty de afbeelding is leeg +@msg the image is fully transparent de afbeelding is volledig transparant +@msg the image is not on an artboard; pass rect de afbeelding staat niet op een tekengebied; geef rect op +@msg the image's pixels are missing de pixels van de afbeelding ontbreken +@msg the image's pixels aren't in the document de pixels van de afbeelding staan niet in het document +@msg the image's pixels can't be read: {e} de pixels van de afbeelding kunnen niet worden gelezen: {e} +@msg the image's transparency doesn't fit it de transparantie van de afbeelding past er niet bij +@msg the job would print more than {MAX_PAGES} pages de taak zou meer dan {MAX_PAGES} pagina’s afdrukken +@msg the job would print more than {_1} pages de taak zou meer dan {_1} pagina’s afdrukken +@msg the layers hold no art to mask de lagen bevatten geen illustratie om te maskeren +@msg the layout moved de indeling is verplaatst +@msg the library has a group `{name}` already de bibliotheek heeft al een groep `{name}` +@msg the library has no group `{name}` de bibliotheek heeft geen groep `{name}` +@msg the library has no item `{item}` de bibliotheek heeft geen item `{item}` +@msg the library has no item `{other}` de bibliotheek heeft geen item `{other}` +@msg the library holds {MAX_GROUPS} groups already de bibliotheek bevat al {MAX_GROUPS} groepen +@msg the library holds {MAX_ITEMS} items of this kind already de bibliotheek bevat al {MAX_ITEMS} items van dit soort +@msg the library's file is outside the read roots het bestand van de bibliotheek staat buiten de leesroots +@msg the line is too long, or the paths it passes too many, to check de lijn is te lang, of de paden die hij passeert zijn te talrijk, om te controleren +@msg the method needs an XML value de methode heeft een XML-waarde nodig +@msg the name is empty de naam is leeg +@msg the object has no fill, stroke or effect het object heeft geen vulling, contour of effect +@msg the object has no graphic style (pass `name`) het object heeft geen grafische stijl (geef `name` op) +@msg the objects don't match the plan de objecten komen niet overeen met het plan +@msg the open and permissions passwords must differ het wachtwoord voor openen en het wachtwoord voor machtigingen moeten verschillen +@msg the operand stack overflowed de operandstack is overgelopen +@msg the page has no area de pagina heeft geen oppervlak +@msg the page's type in an unread object's box isn't in its page art de tekst van de pagina in het kader van een ongelezen object zit niet in de pagina-illustratie ervan +@msg the paint behind the proxy is not a gradient de verf achter de proxy is geen verloop +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) de verf is geen verloop in vrije vorm (pas er een toe met paint.editGradient {kind}) +@msg the parameters or body do not form one function de parameters of de body vormen niet één functie +@msg the parent must be a layer or sublayer het bovenliggende item moet een laag of sublaag zijn +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it het geplakte SVG-filter {name} tekent anders dan het filter met die naam in het document, en er is geen vrije naam die past +@msg the path can't be extended het pad kan niet worden verlengd +@msg the path can't be inside one of the objects to attach het pad kan niet in een van de te koppelen objecten zitten +@msg the path has no segments to measure het pad heeft geen segmenten om te meten +@msg the path is empty het pad is leeg +@msg the path is too short to shorten that much het pad is te kort om zoveel in te korten +@msg the paths are too many or too tangled to find where they cross de paden zijn te talrijk of te verward om te vinden waar ze elkaar kruisen +@msg the pen table holds more than 255 pens de pennentabel bevat meer dan 255 pennen +@msg the picture has no objects de afbeelding heeft geen objecten +@msg the plates can't be kept de platen kunnen niet worden behouden +@msg the position table does not match the instructions de positietabel komt niet overeen met de instructies +@msg the program runs too long het programma loopt te lang +@msg the program uses too much memory het programma gebruikt te veel geheugen +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes de tekst van de prompt is langer dan {MAX_PROMPT_BYTES} bytes +@msg the radix must be between 2 and 36 het grondtal moet tussen 2 en 36 liggen +@msg the rectangle must have a width and a height de rechthoek moet een breedte en een hoogte hebben +@msg the rectangle needs a size and must lie on the canvas de rechthoek heeft een grootte nodig en moet op het canvas liggen +@msg the rectangle or line reaches too many paths to check de rechthoek of lijn raakt te veel paden om te controleren +@msg the right side of 'in' is not an object de rechterkant van 'in' is geen object +@msg the right side of 'instanceof' is not a function de rechterkant van 'instanceof' is geen functie +@msg the selected art has no width de geselecteerde illustratie heeft geen breedte +@msg the selected art is nested more than {MAX_DEPTH} levels deep de geselecteerde illustratie is meer dan {MAX_DEPTH} niveaus diep genest +@msg the selected art is too large to measure de geselecteerde illustratie is te groot om te meten +@msg the selection draws nothing de selectie tekent niets +@msg the selection has no area de selectie heeft geen oppervlakte +@msg the selection has no art to export de selectie heeft geen illustratie om te exporteren +@msg the selection has no bounds de selectie heeft geen grenzen +@msg the selection has no graphic style de selectie heeft geen grafische stijl +@msg the selection has no outlines to weed around de selectie heeft geen omtrekken om omheen te wieden +@msg the slice would leave the canvas de slice zou buiten het canvas vallen +@msg the spacing makes more than {MAX_GROMMETS} grommets de tussenruimte levert meer dan {MAX_GROMMETS} zeilringen op +@msg the spot colour couldn't be added de steunkleur kon niet worden toegevoegd +@msg the subpath has no ends to extend (it is closed or a single point) het subpad heeft geen uiteinden om te verlengen (het is gesloten of een enkel punt) +@msg the symbols are nested too deeply to expand their instances de symbolen zijn te diep genest om hun instanties uit te breiden +@msg the text file is too large to place as type (at most {_1} MB) het tekstbestand is te groot om als tekst te plaatsen (maximaal {_1} MB) +@msg the text is empty de tekst is leeg +@msg the text is over {_1} MB de tekst is groter dan {_1} MB +@msg the top object must be a path het bovenste object moet een pad zijn +@msg the trace was cancelled de tracering is geannuleerd +@msg the trailer has no size de trailer heeft geen grootte +@msg the type object is flattened to nothing het tekstobject is tot niets afgevlakt +@msg the value bind was called on is not a function de waarde waarop bind is aangeroepen, is geen functie +@msg the value is not a constructor de waarde is geen constructor +@msg the value is not a function de waarde is geen functie +@msg the value is not a valid XML name de waarde is geen geldige XML-naam +@msg the value is not an XML attribute name de waarde is geen XML-attribuutnaam +@msg the value is not an XML name de waarde is geen XML-naam +@msg the value of axis `{tag}` must be a number or null de waarde van as `{tag}` moet een getal of null zijn +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets de variabelenbibliotheek declareert meer dan {MAX_LIBRARY_DATASETS} gegevenssets +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities de variabelenbibliotheek declareert meer dan {MAX_LIBRARY_ENTITIES} entiteiten +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables de variabelenbibliotheek declareert meer dan {MAX_LIBRARY_VARIABLES} variabelen +@msg the variable library declares no variables de variabelenbibliotheek declareert geen variabelen +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes de variabelenbibliotheek is groter dan {MAX_LIBRARY_BYTES} bytes +@msg the variable library is not UTF-8 de variabelenbibliotheek is geen UTF-8 +@msg the variable library is not well-formed XML: {e} de variabelenbibliotheek is geen welgevormde XML: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep de variabelenbibliotheek nest elementen meer dan {MAX_LIBRARY_NESTING} niveaus diep +@msg the variable library's document type declaration holds something other than entities of plain text de documenttypedeclaratie van de variabelenbibliotheek bevat iets anders dan entiteiten van platte tekst +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes de entiteiten van de variabelenbibliotheek worden tot meer dan {MAX_LIBRARY_BYTES} bytes uitgevouwen +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines de wiedlijnen kruisen de omtrekken van de illustratie te vaak om te meten; gebruik minder lijnen +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap de wiedlijnen lopen te dicht langs te veel omtreksegmenten om te meten; gebruik minder lijnen of een kleinere tussenruimte +@msg the weeding lines would be more than {max_pieces} pieces de wiedlijnen zouden uit meer dan {max_pieces} stukken bestaan +@msg the window has no dock het venster heeft geen dock +@msg the written PDF has no catalog de geschreven PDF heeft geen catalogus +@msg the written PDF has no cross-reference table de geschreven PDF heeft geen kruisverwijzingstabel +@msg the written PDF's metadata can't be edited de metagegevens van de geschreven PDF kunnen niet worden bewerkt +@msg the {_1} plate lies outside the file de plaat {_1} ligt buiten het bestand +@msg there are no colour swatches to draw er zijn geen kleurstalen om te tekenen +@msg there is already a design named `{name}` er is al een ontwerp met de naam `{name}` +@msg there is already a design named `{to}` er is al een ontwerp met de naam `{to}` +@msg there is already a toolbar named `{name}` er is al een werkbalk met de naam `{name}` +@msg there is already a toolbar named `{to}` er is al een werkbalk met de naam `{to}` +@msg there is no banner to mark er is geen banner om te markeren +@msg there is no library: create one first er is geen bibliotheek: maak er eerst een +@msg these paths are too degenerate for Shaper deze paden zijn te gedegenereerd voor Shaper +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that dit is een binair DXF-bestand, dat niet kan worden gelezen: sla het vanuit de CAD-app op als ASCII-DXF en open dat bestand +@msg this warp's points can't be edited: use Reset with Mesh first de punten van deze verdraaiing kunnen niet worden bewerkt: gebruik eerst Herstellen met mesh +@msg thumbnail: no page {v} (the {what} has {count}) miniatuur: geen pagina {v} ({what} heeft er {count}) +@msg tileRange of {what}: {e} tileRange van {what}: {e} +@msg title can't be empty title mag niet leeg zijn +@msg to (the new name) is required to (de nieuwe naam) is vereist +@msg to must be fill or stroke to moet fill of stroke zijn +@msg to must be smooth or corner to moet smooth of corner zijn +@msg toExponential() digits must be between 0 and 20 het aantal cijfers van toExponential() moet tussen 0 en 20 liggen +@msg toFixed() digits must be between 0 and 20 het aantal cijfers van toFixed() moet tussen 0 en 20 liggen +@msg toISOString is not a function toISOString is geen functie +@msg toPrecision() argument must be between 1 and 21 het argument van toPrecision() moet tussen 1 en 21 liggen +@msg toSource: a getter cannot be written as source toSource: een getter kan niet als broncode worden geschreven +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: een array met eigenschappen naast zijn elementen, een niet-opsombare eigenschap of een ander prototype kan niet als broncode worden geschreven +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: een object met een niet-opsombare eigenschap, of waarvan het prototype niet Object.prototype is, kan niet als broncode worden geschreven +@msg toSource: the source is too long toSource: de broncode is te lang +@msg toSource: the value contains itself toSource: de waarde bevat zichzelf +@msg toSource: the value has too many entries toSource: de waarde heeft te veel items +@msg toSource: the value is nested too deep toSource: de waarde is te diep genest +@msg toString is not a function toString is geen functie +@msg too large for a zip archive: package to a folder te groot voor een zip-archief: verpak naar een map +@msg too many Shaper edits te veel Shaper-bewerkingen +@msg too many `points` (at most 100000) te veel `points` (maximaal 100000) +@msg too many arguments te veel argumenten +@msg too many characters: at most {MAX_CHARS} te veel tekens: maximaal {MAX_CHARS} +@msg too many dots for this size: lower the frequency te veel punten voor deze grootte: verlaag de frequentie +@msg too many filters over one file te veel filters over één bestand +@msg too many overlapping paths for Shaper te veel overlappende paden voor Shaper +@msg too many quantifiers te veel kwantoren +@msg too many saved clips te veel opgeslagen knippaden +@msg too many saved graphics states te veel opgeslagen grafische toestanden +@msg toolbar (a toolbar's name) must be a string toolbar (de naam van een werkbalk) moet een string zijn +@msg toolbar name can't be empty de naam van een werkbalk mag niet leeg zijn +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (de standaard) of "omit" +@msg turn on at least one side schakel minstens één zijde in +@msg two saved selections would be named `{_1}` twee opgeslagen selecties zouden de naam `{_1}` krijgen +@msg type dimension cannot be represented with this text transform de tekstmaatlijn kan niet worden weergegeven met deze teksttransformatie +@msg type dimension must be finite de tekstmaatlijn moet eindig zijn +@msg unexpected resources onverwachte resources +@msg unknown CMYK profile `{name}` onbekend CMYK-profiel `{name}` +@msg unknown File Info field `{k}` onbekend veld `{k}` van Bestandsinfo +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) onbekende OpenType-functie `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; zet er - voor om uit te schakelen; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom en aalt accepteren het nummer van een alternatief als `tag=N` met N van 0 tot {_1}, zoals `salt=2`) +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) onbekende OpenType-functie `{t}` in featureChanges (de tags die `features` accepteert) +@msg unknown PDF preset `{name}` (presets: {_1}) onbekende PDF-voorinstelling `{name}` (voorinstellingen: {_1}) +@msg unknown Warp style onbekende warpstijl +@msg unknown alignToPath `{a}` onbekende alignToPath `{a}` +@msg unknown arrowhead {v} onbekende pijlpunt {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) onbekende sleutel `{k}` van artboardLayout (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` onbekend kenmerk `{attribute}` +@msg unknown attribute `{k}` onbekend kenmerk `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) onbekende as `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` onbekende vermengingsmodus `{b}` +@msg unknown blend mode {b} onbekende vermengingsmodus {b} +@msg unknown brush type `{o}` onbekend penseeltype `{o}` +@msg unknown category `{c}` onbekende categorie `{c}` +@msg unknown category `{c}` ({_1}) onbekende categorie `{c}` ({_1}) +@msg unknown character `{c}` onbekend teken `{c}` +@msg unknown character style number field onbekend getalveld van een tekenstijl +@msg unknown dither `{d}` (diffusion, pattern, noise or none) onbekende dither `{d}` (diffusion, pattern, noise of none) +@msg unknown effect `{e}` onbekend effect `{e}` +@msg unknown end {e} onbekend uiteinde {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) onbekende flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution of een opgeslagen voorinstelling: zie flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) onbekende indeling `{f}` (vcswatches, gpl, ase of css) +@msg unknown from {f} onbekende from {f} +@msg unknown graph type `{t}` onbekend grafiektype `{t}` +@msg unknown grid colours `{name}` (one of {_1}) onbekende rasterkleuren `{name}` (een van {_1}) +@msg unknown gridType `{t}` onbekende gridType `{t}` +@msg unknown highlight `{v}` onbekende markering `{v}` +@msg unknown intent `{i}` onbekende intentie `{i}` +@msg unknown issue kind {id} onbekend soort probleem {id} +@msg unknown issue kind {v} onbekend soort probleem {v} +@msg unknown knockout state {k} onbekende knockoutstatus {k} +@msg unknown language `{language}` (one of {_1}) onbekende taal `{language}` (een van {_1}) +@msg unknown language `{l}` (one of {_1}) onbekende taal `{l}` (een van {_1}) +@msg unknown method `{m}` ({_1}) onbekende methode `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) onbekende modus `{other}` (gray, rgb, hsb, lab, cmyk of web) +@msg unknown mode `{other}` (mono|cmyk) onbekende modus `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) onbekende modus `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) onbekende modus `{other}` (set, add, subtract) +@msg unknown mode `{o}` onbekende modus `{o}` +@msg unknown mode {m} onbekende modus {m} +@msg unknown model `{other}` onbekend model `{other}` +@msg unknown model `{other}` (keep, new or document) onbekend model `{other}` (keep, new of document) +@msg unknown op {o} onbekende op {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back onbekende volgorde `{o}`: toggle, front, forward, backward of back +@msg unknown overprints `{v}` (preserve, simulate or discard) onbekende overprints `{v}` (preserve, simulate of discard) +@msg unknown panel onbekend paneel +@msg unknown panel `{panel}` onbekend paneel `{panel}` +@msg unknown parameter `{key}` onbekende parameter `{key}` +@msg unknown pieLegend `{v}` (none, standard or wedges) onbekende pieLegend `{v}` (none, standard of wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) onbekende piePosition `{v}` (even, ratio of stacked) +@msg unknown pieSort `{v}` (none, all or first) onbekende pieSort `{v}` (none, all of first) +@msg unknown plate `{n}` onbekende plaat `{n}` +@msg unknown preference `{k}` onbekende voorkeur `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) onbekend profiel {id} (zie stroke.widthProfile.list) +@msg unknown proof target `{t}` onbekend proefdoel `{t}` +@msg unknown quotes key `{k}` (double, single) onbekende sleutel `{k}` van quotes (double, single) +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) onbekende reductie `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform of document) +@msg unknown resolution `{v}` onbekende resolutie `{v}` +@msg unknown rule `{rule}` ({_1}) onbekende regel `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) onbekende regel `{r}` (zie color.harmony) +@msg unknown setting `{k}` onbekende instelling `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) onbekende vorm `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) onbekende spacing `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) onbekende tileType `{t}` ({_1}) +@msg unknown units onbekende eenheden +@msg unknown units `{u}` onbekende eenheden `{u}` +@msg unknown value axis `{v}` (left, right or both) onbekende waardeas `{v}` (left, right of both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) onbekende variatie `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` onbekende warpstijl `{s}` +@msg unknown {key} `{v}` (none, short or full) onbekende {key} `{v}` (none, short of full) +@msg unknown {key} profile `{n}` onbekend {key}-profiel `{n}` +@msg unknown {k} key `{key}` (size, position) onbekende sleutel `{key}` van {k} (size, position) +@msg unmatched ')' ')' zonder tegenhanger +@msg unreadable object onleesbaar object +@msg unsafe destination file name onveilige naam voor het doelbestand +@msg unterminated character class niet-afgesloten tekenklasse +@msg unterminated group niet-afgesloten groep +@msg use paths or compound paths gebruik paden of samengestelde paden +@msg useArtboards must be true or false, not {v} useArtboards moet true of false zijn, niet {v} +@msg value for `{var}` must match its variable kind de waarde voor `{var}` moet bij het soort variabele passen +@msg variable `{_1}` is already defined variabele `{_1}` is al gedefinieerd +@msg variable `{name}` has no `trait` variabele `{name}` heeft geen `trait` +@msg variable `{name}` is already defined variabele `{name}` is al gedefinieerd +@msg variable `{name}` is declared twice variabele `{name}` is twee keer gedeclareerd +@msg variable `{to}` already exists variabele `{to}` bestaat al +@msg view is required view is vereist +@msg view must be one of {_1} view moet een van {_1} zijn +@msg warpStyle can only edit an applied Warp effect warpStyle kan alleen een toegepast warpeffect bewerken +@msg width and height must be more than 0 and at most {limit} width en height moeten meer dan 0 en maximaal {limit} zijn +@msg width factors must be finite breedtefactoren moeten eindig zijn +@msg width must be positive width moet positief zijn +@msg width out of range breedte buiten het bereik +@msg width/height must be > 0 width/height moeten > 0 zijn +@msg with no hem and no grommets there is nothing to mark zonder zoom en zonder zeilringen is er niets om te markeren +@msg wordGap and lineGap need area type in the selection wordGap en lineGap hebben vlaktekst in de selectie nodig +@msg x and y must be finite numbers x en y moeten eindige getallen zijn +@msg x and y must be given together x en y moeten samen worden opgegeven +@msg xpath needs its variables as an object xpath heeft zijn variabelen als object nodig +@msg xpath needs the expression as a string xpath heeft de expressie als string nodig +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone heeft een anker nodig: dock naast een paneel met anchor, of in een nieuwe dockkolom met side (Start of End) +@msg zoom must be positive zoom moet positief zijn +@msg {C}: no effect at index {list} {C}: geen effect op index {list} +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} is beveiligd en blijft bestaan +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} is beveiligd: sla de instellingen onder een andere naam op +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}, en de instellingen en cache ervan kunnen niet van {_1} naar {_2} worden teruggezet: {e} +@msg {LABEL} produced an empty result {LABEL} heeft een leeg resultaat opgeleverd +@msg {_1} MB of document, at most {_2} MB {_1} MB aan document, maximaal {_2} MB +@msg {_1} MB of images, at most {_2} MB {_1} MB aan afbeeldingen, maximaal {_2} MB +@msg {_1} bytes of RGBA data for a {width} × {height} image {_1} bytes aan RGBA-gegevens voor een afbeelding van {width} × {height} +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} kan niet worden gelezen, of is gewijzigd sinds het is gelezen: koppel het eerst opnieuw of werk het eerst bij +@msg {_1} can't go into {into} {_1} kan niet in {into} worden geplaatst +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} kan afdrukken niet tot lage resolutie beperken: kies PDF 1.4 of later, of sta afdrukken in hoge resolutie toe +@msg {_1} files can't be opened yet {_1}-bestanden kunnen nog niet worden geopend +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1}-bestanden kunnen niet met een wachtwoord worden beveiligd: verwijder de wachtwoorden of kies geen standaard +@msg {_1} files can't be {_2} {_1}-bestanden kunnen niet {_2} zijn +@msg {_1} files can't carry editing data: turn off preserveEditing {_1}-bestanden kunnen geen bewerkingsgegevens bevatten: schakel preserveEditing uit +@msg {_1} files can't have PDF layers: turn off createLayers {_1}-bestanden kunnen geen PDF-lagen hebben: schakel createLayers uit +@msg {_1} files need a creation date, and this platform has no clock to give one {_1}-bestanden hebben een aanmaakdatum nodig, en dit platform heeft geen klok om er een te geven +@msg {_1} is not a folder: {e} {_1} is geen map: {e} +@msg {_1} isn't a path {_1} is geen pad +@msg {_1} options: {e} opties voor {_1}: {e} +@msg {_1} paints nothing to rasterize {_1} verft niets om te rasteriseren +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) wachtwoorden voor {_1} mogen maximaal {MAX_LEGACY_PASSWORD} ASCII-tekens hebben (PDF 1.7 en later accepteren alle tekens) +@msg {_1} with linked images or an image map writes several files: use document.export {_1} met gekoppelde afbeeldingen of een imagemap schrijft meerdere bestanden: gebruik document.export +@msg {_1} writes one file per artboard here: use document.export, or name one artboard {_1} schrijft hier één bestand per tekengebied: gebruik document.export, of noem één tekengebied +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} pixels is te groot voor {_3} (maximaal {max} pixels per zijde): verlaag de schaal +@msg {_1}: belongs to another account {_1}: hoort bij een ander account +@msg {_1}: not a folder of its own {_1}: geen eigen map +@msg {_1}: not a plain file {_1}: geen gewoon bestand +@msg {_1}: {_2} and {_3} are in the way {_1}: {_2} en {_3} staan in de weg +@msg {code}: {0} {code}: {0} +@msg {dest}: {e} {dest}: {e} +@msg {dir}: {e} {dir}: {e} +@msg {doc} has no folder {doc} heeft geen map +@msg {file} isn't linked {file} is niet gekoppeld +@msg {file} names more than one linked JavaScript file {file} noemt meer dan één gekoppeld JavaScript-bestand +@msg {folder} is not a package folder {folder} is geen pakketmap +@msg {folder}: more than {max} files and folders {folder}: meer dan {max} bestanden en mappen +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} is geen Live Paint-groep +@msg {id} is not a slice {id} is geen slice +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} moet 0–{MAX_ADD_AROUND} pt zijn +@msg {key} must be [x,y] {key} moet [x,y] zijn +@msg {key} must be a percentage {min}..100 {key} moet een percentage {min}..100 zijn +@msg {key} must be a positive number, not {n} {key} moet een positief getal zijn, niet {n} +@msg {key} must be a positive number, not {v} {key} moet een positief getal zijn, niet {v} +@msg {key} must be one of {_1} {key} moet een van {_1} zijn +@msg {key} must be {_1}, got {s} {key} moet {_1} zijn, kreeg {s} +@msg {key} must name a floating panel {key} moet een zwevend paneel noemen +@msg {key} must name a panel in another group {key} moet een paneel in een andere groep noemen +@msg {key} must name a panel that shows {key} moet een paneel noemen dat wordt getoond +@msg {key} must name a registered panel {key} moet een geregistreerd paneel noemen +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind}-bestanden hebben geen transparantie, en het bestand zou {what} bevatten: vlak het eerst af (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind}-afbeeldingen kunnen niet worden geopend (zie document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}` is geen van de keuzes +@msg {k} has control characters{_1} {k} bevat besturingstekens{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k} is te lang (maximaal {MAX_TEXT} tekens) +@msg {k} must be > 0 {k} moet > 0 zijn +@msg {k} must be a number (pt) or a length such as "210 mm" {k} moet een getal (pt) of een lengte zoals "210 mm" zijn +@msg {k} must be a number from {min} to {max} {k} moet een getal van {min} tot {max} zijn +@msg {k} must be a number or "auto" {k} moet een getal of "auto" zijn +@msg {k} must be a string {k} moet een string zijn +@msg {k} must be text {k} moet tekst zijn +@msg {k} must be true or false {k} moet true of false zijn +@msg {k} must be {{size?, position?}} {k} moet {{size?, position?}} zijn +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: het pad van een aangepaste pijlpunt heeft een oppervlak om te vullen nodig en maximaal {most} ankers en {most} subpaden, elk anker binnen {far} kopeenheden van de punt +@msg {k}: bad custom arrowhead path: {e} {k}: ongeldig pad van een aangepaste pijlpunt: {e} +@msg {k}: bad custom arrowhead: {e} {k}: ongeldige aangepaste pijlpunt: {e} +@msg {k}: can't read `{s}` as a length {k}: kan `{s}` niet als lengte lezen +@msg {k}: custom must be a name {k}: custom moet een naam zijn +@msg {label} must be a number at most {max} {label} moet een getal van maximaal {max} zijn +@msg {label} must be a number from -1296 to 1296 {label} moet een getal van -1296 tot 1296 zijn +@msg {label} must be a number from 1 to 10000 {label} moet een getal van 1 tot 10000 zijn +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} is ingebouwd en kan niet worden verwijderd +@msg {name} must be a number {name} moet een getal zijn +@msg {name}: CRC or size mismatch {name}: CRC of grootte komt niet overeen +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: alleen de ingesloten voorvertoning van dit Affinity-bestand kon worden gelezen; gebruik Bestand › Openen om die met de bijbehorende waarschuwing te bekijken, of exporteer SVG of PDF vanuit Affinity voordat je plaatst +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; de ingesloten voorvertoning ervan kan ook niet worden gelezen ({e}) +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy accepteert een DCS-bestand alleen wanneer de platen erin zitten; koppel het opnieuw op de plek waar de plaatbestanden staan +@msg {path}: larger than {_1} MB {path}: groter dan {_1} MB +@msg {saving} failed: {e} {saving} is mislukt: {e} +@msg {shown} is not an XPath 1.0 function {shown} is geen XPath 1.0-functie +@msg {std} files are CMYK, and the file would have colours in {_1} {std}-bestanden zijn CMYK, en het bestand zou kleuren in {_1} bevatten +@msg {std} files embed their fonts, and the file would have one that isn't {std}-bestanden sluiten hun lettertypen in, en het bestand zou er een bevatten dat niet is ingesloten +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} is het bestand waaruit dit document is gelezen, en bij het lezen zijn dingen weggelaten ({what}): door eroverheen te schrijven zouden die voorgoed verloren gaan. Schrijf in plaats daarvan een ander bestand, of geef acknowledgeLoss: true op om het toch te vervangen +@msg {ty} brushes need selected art (or params.{k}) {ty}-penselen hebben een geselecteerde illustratie nodig (of params.{k}) +@msg {v} is not an asset id {v} is geen asset-id +@msg {v} names no layer or sublayer {v} noemt geen laag of sublaag +@msg {what} can't be intertwined {what} kan niet worden verstrengeld +@msg {what} doesn't fit the paper with its bleed and marks {what} past niet op het papier met de afloop en de tekens ervan +@msg {what} is not a function {what} is geen functie +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what} zou meer dan {MAX_COPIES_PER_SHEET} exemplaren op een vel afdrukken: schaal het omhoog +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} zou op meer dan {MAX_TILES} tegels worden afgedrukt: schaal het omlaag +@msg {what} {i} out of range {what} {i} buiten het bereik + Ruler Origin Lineaaloorsprong Reset Ruler Origin Lineaaloorsprong herstellen Column Width: Kolombreedte: @@ -7502,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 @@ -7525,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 5b7b3e60ac..08e96e6a35 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1514,6 +1514,7 @@ lighting Light Światło Proportional Metrics Metryka proporcjonalna Insert Aki Left Wstaw aki z lewej Insert Aki Right Wstaw aki z prawej + Tsume Tsume Character Direction Kierunek znaków Digits Cyfry Arabic Arabskie @@ -4878,6 +4879,7 @@ imageTrace Outline Kontur @msg no action set to save brak zestawu operacji do zapisania @msg the action set file can't be read in full nie można odczytać całego pliku zestawu operacji @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków +@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}` Wczytano „{name}”: {n} krok ma nieznane polecenie i został zachowany: „{action}”, krok {step}, `{command}`|Wczytano „{name}”: {n} kroki mają nieznane polecenia i zostały zachowane; pierwszy: „{action}”, krok {step}, `{command}`|Wczytano „{name}”: {n} kroków ma nieznane polecenia i zostało zachowanych; pierwszy: „{action}”, krok {step}, `{command}` @msg not an action set file to nie jest plik zestawu operacji @msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: dokumentacja byłaby większa niż {MAX_BYTES} bajtów @@ -5121,6 +5123,7 @@ imageTrace Outline Kontur @msg tool shortcuts must be a single key (optionally with Shift) skróty narzędzi muszą być pojedynczym klawiszem (opcjonalnie z Shift) @msg truncated obcięty @msg unknown command `{0}` nieznane polecenie `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` krok {step} operacji „{action}” uruchamia nieznane polecenie `{command}` @msg unknown command `{0}`; did you mean `{1}`? nieznane polecenie `{0}`; czy chodziło o `{1}`? @msg unknown effect `{id}` nieznany efekt `{id}` @msg unknown format nieznany format @@ -6248,8 +6251,8 @@ axis Y Axis Oś Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka eksportu deklaruje od 1 do {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` najwyżej {MAX_UNGROUPED} parametrów poza grupami: pozostałym nadaj `group` @msg can't encode the input nie można zakodować danych wejściowych -@msg expected {"dataBase64"} oczekiwano {"dataBase64"} -@msg expected {"objects"} oczekiwano {"objects"} +@msg expected {{"dataBase64": "…"}} oczekiwano {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} oczekiwano {{"objects": [...]}} @msg export `{name}`: {e} eksport modułu `{name}`: {e} @msg extension {e} is listed twice rozszerzenie {e} występuje dwa razy @msg extension {e}: use 1-16 lower-case letters and digits, without the dot rozszerzenie {e}: użyj 1-16 małych liter i cyfr, bez kropki @@ -6313,11 +6316,11 @@ axis Y Axis Oś Y @msg a painted live effect returns only `objects` malujący efekt dynamiczny zwraca tylko `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` element malującego efektu dynamicznego ma tylko `path`, `fillRule`, `fills` i `strokes` @msg a read-only object was left out pominięto obiekt tylko do odczytu -@msg a read-only object was left out: return it, as {"id"} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {"id"} lub w postaci, w jakiej został przekazany +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {{"id": …}} lub w postaci, w jakiej został przekazany @msg a string ciągiem znaków @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` obrys ma tylko `paint`, `width`, `join`, `cap` i `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} obrys musi mieć postać {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} efekt musi mieć postać {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} obrys musi mieć postać {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} efekt musi mieć postać {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` efekt wypełnienia zwraca tylko `fills` @msg an effect on a stroke returns only `strokes` efekt obrysu zwraca tylko `strokes` @msg an object must be a JSON object obiekt musi być obiektem JSON @@ -7326,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 @@ -9165,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 90825f967f..79c4d9dc8e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2232,6 +2232,7 @@ antiAlias Sharp Nítido Proportional Metrics Métrica Proporcional Insert Aki Left Inserir Aki à Esquerda Insert Aki Right Inserir Aki à Direita + Tsume Tsume Character Direction Direção de Caracteres Digits Dígitos Arabic Arábicos @@ -4198,6 +4199,7 @@ axis Both Ambos @msg no action set to save nenhum conjunto de ações para salvar @msg the action set file can't be read in full o arquivo de conjunto de ações não pode ser lido por inteiro @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas +@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}” carregado: {n} etapa tem um comando desconhecido e foi mantida: “{action}”, etapa {step}, `{command}`|“{name}” carregado: {n} etapas têm comandos desconhecidos e foram mantidas; a primeira: “{action}”, etapa {step}, `{command}` @msg not an action set file não é um arquivo de conjunto de ações @msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referência teria mais de {MAX_BYTES} bytes @@ -4437,6 +4439,7 @@ axis Both Ambos @msg tool shortcuts must be a single key (optionally with Shift) os atalhos de ferramenta devem ser uma única tecla (opcionalmente com Shift) @msg truncated truncado @msg unknown command `{0}` comando desconhecido `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` a etapa {step} de “{action}” executa o comando desconhecido `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando desconhecido `{0}`; você quis dizer `{1}`? @msg unknown effect `{id}` efeito desconhecido `{id}` @msg unknown format formato desconhecido @@ -6308,8 +6311,8 @@ axis Y Axis Eixo Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de exportação declara de 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` no máximo {MAX_UNGROUPED} parâmetros fora de grupos: dê um `group` aos outros @msg can't encode the input não é possível codificar a entrada -@msg expected {"dataBase64"} esperado {"dataBase64"} -@msg expected {"objects"} esperado {"objects"} +@msg expected {{"dataBase64": "…"}} esperado {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} esperado {{"objects": [...]}} @msg export `{name}`: {e} exportação do módulo `{name}`: {e} @msg extension {e} is listed twice a extensão {e} aparece duas vezes @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensão {e}: use 1-16 letras minúsculas e dígitos, sem o ponto @@ -6373,11 +6376,11 @@ axis Y Axis Eixo Y @msg a painted live effect returns only `objects` um efeito dinâmico de pintura retorna apenas `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` uma peça de um efeito dinâmico de pintura tem apenas `path`, `fillRule`, `fills` e `strokes` @msg a read-only object was left out um objeto somente leitura foi omitido -@msg a read-only object was left out: return it, as {"id"} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {"id"} sozinho ou como foi recebido +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {{"id": …}} sozinho ou como foi recebido @msg a string uma string @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` um traçado tem apenas `paint`, `width`, `join`, `cap` e `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} um traçado deve ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} um efeito deve ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} um traçado deve ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} um efeito deve ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` um efeito em um preenchimento retorna apenas `fills` @msg an effect on a stroke returns only `strokes` um efeito em um traçado retorna apenas `strokes` @msg an object must be a JSON object um objeto deve ser um objeto JSON @@ -7048,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 @@ -7276,7 +7395,7 @@ swatch Lime Lima Line Weight: Espessura da linha: Load Variable Library Carregar biblioteca de variáveis swatch Magenta Magenta -@msg not an RTF file: it doesn't start with {\\rtf não é um arquivo RTF: ele não começa com {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf não é um arquivo RTF: ele não começa com {{\\rtf swatch Ocean Oceano shape Open Aberta swatch Orange Laranja @@ -7542,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 a235e2c9b3..9f608618e7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2695,6 +2695,7 @@ mark-style Japanese Японский Proportional Metrics Пропорциональные метрики Insert Aki Left Вставить аки слева Insert Aki Right Вставить аки справа + Tsume Цумэ Character Direction Направление символов Digits Цифры Arabic Арабские @@ -4781,6 +4782,7 @@ axis Both Оба @msg no action set to save нет набора действий для сохранения @msg the action set file can't be read in full файл набора действий невозможно прочитать целиком @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов +@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}»: {n} шаг с неизвестной командой сохранён: «{action}», шаг {step}, `{command}`|Загружено «{name}»: {n} шага с неизвестными командами сохранены; первый: «{action}», шаг {step}, `{command}`|Загружено «{name}»: {n} шагов с неизвестными командами сохранены; первый: «{action}», шаг {step}, `{command}` @msg not an action set file это не файл набора действий @msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: справочник был бы больше {MAX_BYTES} байт @@ -5022,6 +5024,7 @@ axis Both Оба @msg tool shortcuts must be a single key (optionally with Shift) сочетания инструментов должны быть одной клавишей (необязательно с Shift) @msg truncated усечено @msg unknown command `{0}` неизвестная команда `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` шаг {step} действия «{action}» выполняет неизвестную команду `{command}` @msg unknown command `{0}`; did you mean `{1}`? неизвестная команда `{0}`; возможно, имелась в виду `{1}`? @msg unknown effect `{id}` неизвестный эффект `{id}` @msg unknown format неизвестный формат @@ -6004,8 +6007,8 @@ axis Y Axis Ось Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль экспорта объявляет от 1 до {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` вне групп может быть не более {MAX_UNGROUPED} параметров: задайте остальным `group` @msg can't encode the input не удаётся закодировать входные данные -@msg expected {"dataBase64"} ожидалось {"dataBase64"} -@msg expected {"objects"} ожидалось {"objects"} +@msg expected {{"dataBase64": "…"}} ожидалось {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} ожидалось {{"objects": [...]}} @msg export `{name}`: {e} экспорт модуля `{name}`: {e} @msg extension {e} is listed twice расширение {e} указано дважды @msg extension {e}: use 1-16 lower-case letters and digits, without the dot расширение {e}: используйте 1-16 строчных букв и цифр, без точки @@ -6069,11 +6072,11 @@ axis Y Axis Ось Y @msg a painted live effect returns only `objects` рисующий динамический эффект возвращает только `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` у части рисующего динамического эффекта есть только `path`, `fillRule`, `fills` и `strokes` @msg a read-only object was left out пропущен объект только для чтения -@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущен объект только для чтения: верните его как один {"id"} или в том виде, в каком он был передан +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given пропущен объект только для чтения: верните его как один {{"id": …}} или в том виде, в каком он был передан @msg a string строка @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` у обводки есть только `paint`, `width`, `join`, `cap` и `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обводка должна иметь вид {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} эффект должен иметь вид {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} обводка должна иметь вид {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} эффект должен иметь вид {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` эффект на заливке возвращает только `fills` @msg an effect on a stroke returns only `strokes` эффект на обводке возвращает только `strokes` @msg an object must be a JSON object объект должен быть объектом JSON @@ -7381,7 +7384,7 @@ breakText Glyphs Глифы @id pathfinder.add Объединить @msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами @msg image '{key}' has no pixels and was left out изображение '{key}' не содержит пикселов и было пропущено -@msg not an RTF file: it doesn't start with {\\rtf это не файл RTF: он не начинается с {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf это не файл RTF: он не начинается с {{\\rtf @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG это файл PostScript (.ai, сохранённый в старом формате или без совместимости с PDF, либо EPS-файл), который пока нельзя открыть: сохраните его как PDF, .ai с совместимостью с PDF или SVG @msg this kind of brush has no options dialog yet у кистей этого типа пока нет диалога параметров digits Hindi Хинди @@ -7558,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 b491d33649..6156ca23d5 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2603,6 +2603,7 @@ font-technology Variable Variabla Proportional Metrics Proportionella mått Insert Aki Left Infoga aki till vänster Insert Aki Right Infoga aki till höger + Tsume Tsume Character Direction Teckenriktning Digits Siffror Arabic Arabiska @@ -4644,6 +4645,7 @@ axis Both Båda @msg no action set to save ingen åtgärdsuppsättning att spara @msg the action set file can't be read in full filen med åtgärdsuppsättning kan inte läsas helt @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades +@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}` Läste in ”{name}”: {n} steg har ett okänt kommando och behölls: ”{action}”, steg {step}, `{command}`|Läste in ”{name}”: {n} steg har okända kommandon och behölls; det första: ”{action}”, steg {step}, `{command}` @msg not an action set file inte en fil med åtgärdsuppsättning @msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: referensen skulle bli större än {MAX_BYTES} byte @@ -4878,6 +4880,7 @@ axis Both Båda @msg tool shortcuts must be a single key (optionally with Shift) kortkommandon för verktyg måste vara en enda tangent (eventuellt med Shift) @msg truncated avkortad @msg unknown command `{0}` okänt kommando `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` steg {step} i ”{action}” kör det okända kommandot `{command}` @msg unknown command `{0}`; did you mean `{1}`? okänt kommando `{0}`; menade du `{1}`? @msg unknown effect `{id}` okänd effekt `{id}` @msg unknown format okänt format @@ -6443,11 +6446,11 @@ axis Y Axis Y-axel @msg a painted live effect returns only `objects` en målande live-effekt returnerar bara `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` en del av en målande live-effekt har bara `path`, `fillRule`, `fills` och `strokes` @msg a read-only object was left out ett skrivskyddat objekt utelämnades -@msg a read-only object was left out: return it, as {"id"} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {"id"} eller som det gavs +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {{"id": …}} eller som det gavs @msg a string en sträng @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` en kontur har bara `paint`, `width`, `join`, `cap` och `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} en kontur måste vara {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} en effekt måste vara {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} en kontur måste vara {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} en effekt måste vara {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` en effekt på en fyllning returnerar bara `fills` @msg an effect on a stroke returns only `strokes` en effekt på en kontur returnerar bara `strokes` @msg an object must be a JSON object ett objekt måste vara ett JSON-objekt @@ -6994,7 +6997,7 @@ anchor Points: Punkter: VectorCraft Updates Uppdateringar för VectorCraft Yonroku-ban Yonroku-ban @msg image '{key}' has no pixels and was left out bilden '{key}' har inga pixlar och utelämnades -@msg not an RTF file: it doesn't start with {\\rtf inte en RTF-fil: den börjar inte med {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf inte en RTF-fil: den börjar inte med {{\\rtf shape Open Öppen # Color Effects add-on: Select by Color Kind. @@ -7593,7 +7596,7 @@ digits Hindi Hindi @msg `expand` must be true or false `expand` måste vara true eller false @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` måste vara 1 till 1000 sökvägar till typsnittsfiler som den senaste text.findFontFiles-sökningen hittade @msg `folder` must be an absolute folder path `folder` måste vara en absolut mappsökväg -@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` måste vara ett objekt med axeltaggar och värden, till exempel {"wght"} +@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` måste vara ett objekt med axeltaggar och värden, till exempel {{"wght": 650}} @msg `format` must be a string `format` måste vara en sträng @msg `format` must be one of {_1} `format` måste vara något av {_1} @msg `fractions` is chop or scale `fractions` är chop eller scale @@ -7616,12 +7619,12 @@ digits Hindi Hindi @msg `index` must be a point index `index` måste vara ett punktindex @msg `index` must be a whole number or null `index` måste vara ett heltal eller null @msg `indices` must be a non-empty array of item indices `indices` måste vara en icke-tom array av objektindex -@msg `items` is a list of {kind, item} `items` är en lista med {kind, item} +@msg `items` is a list of {{kind, item}} `items` är en lista med {{kind, item}} @msg `items` is empty `items` är tom @msg `items` must be a list of {{id, anchors?}}, not {v} `items` måste vara en lista med {{id, anchors?}}, inte {v} -@msg `items` must be a list of {id, points} `items` måste vara en lista med {id, points} +@msg `items` must be a list of {{id, points}} `items` måste vara en lista med {{id, points}} @msg `items` must be an array of {{id, corners?}}, not {v} `items` måste vara en array av {{id, corners?}}, inte {v} -@msg `items` must be an array of {id, anchors} `items` måste vara en array av {id, anchors} +@msg `items` must be an array of {{id, anchors}} `items` måste vara en array av {{id, anchors}} @msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` måste vara "none", "short", "medium", "long" eller "stylistic" @msg `kind` is one of {_1} `kind` är något av {_1} @msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` måste vara "round", "invertedRound" eller "chamfer", inte {v} @@ -7787,7 +7790,7 @@ digits Hindi Hindi @msg artboard {i} doesn't print rityta {i} skrivs inte ut @msg artboard is a 0-based index artboard är ett 0-baserat index @msg artboard must be a 0-based index artboard måste vara ett 0-baserat index -@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout måste vara {layout?, columns?, spacing?, rightToLeft?} +@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout måste vara {{layout?, columns?, spacing?, rightToLeft?}} @msg artboardLayout.columns must be 1 or more artboardLayout.columns måste vara 1 eller mer @msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout måste vara gridByRow, gridByColumn, row eller column @msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft måste vara true eller false @@ -7935,13 +7938,13 @@ digits Hindi Hindi @msg DXF scale must be a positive number up to {_1}, not {scale} DXF-skalan måste vara ett positivt tal upp till {_1}, inte {scale} @msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf-enhet `{u}`: en enhet som mm, cm, in eller pt @msg dxf: {e} dxf: {e} -@msg each format is an object {format, scale?, suffix?} varje format är ett objekt {format, scale?, suffix?} +@msg each format is an object {{format, scale?, suffix?}} varje format är ett objekt {{format, scale?, suffix?}} @msg each item needs an id varje objekt behöver ett id @msg each match needs `kind` varje matchning behöver `kind` @msg each match needs a `name` varje matchning behöver ett `name` -@msg each of `fonts` is {family, style?}; 1 to 1000 of them varje post i `fonts` är {family, style?}; 1 till 1000 stycken +@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them varje post i `fonts` är {{family, style?}}; 1 till 1000 stycken @msg each of `items` is {{id, corners?}} with an object id, not {it} varje post i `items` är {{id, corners?}} med ett objekt-id, inte {it} -@msg each of `items` is {kind, item} varje post i `items` är {kind, item} +@msg each of `items` is {{kind, item}} varje post i `items` är {{kind, item}} @msg each row needs `from` varje rad behöver `from` @msg each stop needs a position varje stopp behöver en position @msg each subpath needs an anchors array varje delbana behöver en anchors-array @@ -7959,8 +7962,8 @@ digits Hindi Hindi @msg EPS Save writes all artwork: use document.export for selected-only EPS När du sparar som EPS skrivs allt bildmaterial: använd document.export för EPS med bara det markerade @msg every numbered name for the result is taken alla numrerade namn för resultatet är upptagna @msg expanded SVG styles exceed the import size limit de expanderade SVG-stilarna överskrider storleksgränsen för import -@msg expected {"dataBase64"} förväntade {"dataBase64"} -@msg expected {"objects"} förväntade {"objects"} +@msg expected {{"dataBase64": "…"}} förväntade {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} förväntade {{"objects": [...]}} @msg export `{name}`: {e} export av `{name}`: {e} @msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Exportera för skärmar skriver png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai eller vectorcraft @msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes filen med exportförinställningar är större än {MAX_PRESET_FILE_BYTES} byte @@ -7973,7 +7976,7 @@ digits Hindi Hindi @msg featureChanges must be a list of tags featureChanges måste vara en lista med taggar @msg features must be a list of tags features måste vara en lista med taggar @msg fidelity must be 0..100 fidelity måste vara 0..100 -@msg files must be {name, dataBase64} files måste vara {name, dataBase64} +@msg files must be {{name, dataBase64}} files måste vara {{name, dataBase64}} @msg fit must be none|autoHeight|shrinkText|block, got {id} fit måste vara none|autoHeight|shrinkText|block, fick {id} @msg Flare click coordinates must be finite numbers Klickkoordinaterna för Ljusreflex måste vara ändliga tal @msg flattener: {e} utplattning: {e} @@ -7991,7 +7994,7 @@ digits Hindi Hindi @msg frequency must be between 1 and 300 lines per inch frequency måste vara mellan 1 och 300 linjer per tum @msg from and to must be finite from och to måste vara ändliga @msg from must be [x0, y0, x1, y1] from måste vara [x0, y0, x1, y1] -@msg give {key, value} or {values} ange {key, value} eller {values} +@msg give {{key, value}} or {{values}} ange {{key, value}} eller {{values}} @msg give `before` or `after`, not both ange `before` eller `after`, inte båda @msg give `char` or `text` ange `char` eller `text` @msg give `colors`, or `color` and `rule` ange `colors`, eller `color` och `rule` @@ -8006,7 +8009,7 @@ digits Hindi Hindi @msg give `id` and `anchors`, or `items`, not both ange `id` och `anchors`, eller `items`, inte båda @msg give `id` or `guide`, not both ange `id` eller `guide`, inte båda @msg give `length` or `to` ange `length` eller `to` -@msg give `matches`: [{kind, name, target}] ange `matches`: [{kind, name, target}] +@msg give `matches`: [{{kind, name, target}}] ange `matches`: [{{kind, name, target}}] @msg give `name` (or edit a pattern) ange `name` (eller redigera ett mönster) @msg give `name` or `names` ange `name` eller `names` @msg give `path` (a .wasm file) or `dataBase64` (the module) ange `path` (en .wasm-fil) eller `dataBase64` (modulen) @@ -8042,7 +8045,7 @@ digits Hindi Hindi @msg give the preset a `name` ge förinställningen ett `name` @msg give the preset a name ge förinställningen ett namn @msg give the preset a name (1–64 characters) ge förinställningen ett namn (1–64 tecken) -@msg give the settings to change, e.g. {"formats"}]} ange inställningarna som ska ändras, t.ex. {"formats"}]} +@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} ange inställningarna som ska ändras, t.ex. {{"formats": [{{"format": "png", "scale": "2x"}}]}} @msg give the style a name ge stilen ett namn @msg give two or more style `names` ange två eller fler stilnamn i `names` @msg give two or more swatches ange två eller fler färgrutor @@ -8147,7 +8150,7 @@ digits Hindi Hindi @msg missing `library` `library` saknas @msg missing `library` (see graphicStyle.libraries) `library` saknas (se graphicStyle.libraries) @msg missing `library` (see swatch.library.list) `library` saknas (se swatch.library.list) -@msg missing `map` (rows [{from, to}] or {current}) `map` saknas (rader [{from, to}] eller {current}) +@msg missing `map` (rows [{{from, to}}] or {{current: new}}) `map` saknas (rader [{{from, to}}] eller {{current: new}}) @msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] saknas @msg missing `name` (see gradient.presets.list) `name` saknas (se gradient.presets.list) @msg missing `offset` (a distance in points) `offset` saknas (ett avstånd i punkter) @@ -8439,7 +8442,7 @@ digits Hindi Hindi @msg print settings: {e} utskriftsinställningar: {e} @msg procedures nest too deeply procedurerna är nästlade för djupt @msg procedures nested too deeply procedurerna nästlades för djupt -@msg quotes must be {double?, single?} quotes måste vara {double?, single?} +@msg quotes must be {{double?, single?}} quotes måste vara {{double?, single?}} @msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} måste vara ett inledande och ett avslutande citattecken, t.ex. "“”" @msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi måste vara 1–2400 (72 skärm, 150 medel, 300 hög) @msg rating must be a whole number from 0 to {MAX_RATING} rating måste vara ett heltal från 0 till {MAX_RATING} @@ -8551,13 +8554,13 @@ digits Hindi Hindi @msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG-filen är inte i UTF-8, UTF-16 eller ISO 8859-1 @msg swatchConflict must be "merge" or "add", not {v} swatchConflict måste vara "merge" eller "add", inte {v} @msg Switch x/y is for scatter graphs Växla x/y gäller spridningsdiagram -@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text måste vara {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} +@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text måste vara {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} @msg Text options: {e} Textalternativ: {e} @msg text transform is collapsed or unrepresentable texttransformeringen är kollapsad eller kan inte återges @msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: använd "{_1}" eller "{_2}" @msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces måste vara ett heltal från 2 till 100 @msg textureImage: {e} textureImage: {e} -@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: ange {path} eller {name, dataBase64} för en bildfil, eller en textur så som effect.list anger den +@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: ange {{path}} eller {{name, dataBase64}} för en bildfil, eller en textur så som effect.list anger den @msg that makes more than {MAX_PIECES} slices det ger fler än {MAX_PIECES} skivor @msg that object is not in the overlap det objektet ingår inte i överlappningen @msg the action set ids are not a list åtgärdsuppsättningarnas id:n är inte en lista @@ -8907,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 @@ -8973,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 38414e5b9e..f01af3e32f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2088,6 +2088,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!( @@ -2170,6 +2208,55 @@ fn message_keys_keep_doubled_braces() { assert_eq!(as_template(r"it doesn't start with {\rtf"), r"it doesn't start with {\rtf"); } +/// The braces of a message that is not a format string (a plain literal or a `&str` constant) are +/// literal, so its catalog key doubles them; a `format!` string or an `#[error(…)]` string keeps +/// its `{{`, `}}` and placeholders. +#[test] +fn plain_message_keys_double_their_braces() { + let code = concat!( + "const SHAPE: &str = \"a shape is {\\\"kind\\\": …}\";\n", + "fn f() -> Result<()> { Err(Error::Output(\"expected {\\\"dataBase64\\\": \\\"…\\\"}\".into())) }\n", + "fn g() -> Result<()> { Err(SHAPE.into()) }\n", + "fn h() -> Result<()> { Err(bad(C, \"not an RTF file: it doesn't start with {\\\\rtf\")) }\n", + "fn i() -> Result<()> { Err(bad(C, format!(\"`{key}` must be {{min?, max?}}\"))) }\n", + "#[error(\"the file is {0} bytes, not {{size}}\")]\n", + ); + let consts: std::collections::BTreeMap> = str_consts(code).into_iter().map(|(name, value)| (name, vec![value])).collect(); + // The engine's `error(` helper also occurs in each `#[error(` attribute. + let found: std::collections::BTreeSet = source_messages(code, &[("bad(", 1), ("error(", 0)], &consts).into_iter().collect(); + let want = [ + r#"expected {{"dataBase64": "…"}}"#, + r#"a shape is {{"kind": …}}"#, + r"not an RTF file: it doesn't start with {{\rtf", + "`{key}` must be {{min?, max?}}", + "the file is {0} bytes, not {{size}}", + ]; + assert_eq!(found, want.iter().map(|s| s.to_string()).collect::>(), "{found:#?}"); + assert_eq!(as_literal_key("a {b} c}"), "a {{b}} c}}"); +} + +/// A catalog row for a message with literal braces translates that message and no other: it has no +/// placeholder to take the rest of a message with the same start. +#[test] +fn literal_brace_rows_match_only_their_message() { + let c = Catalog::parse("@msg\texpected {{\"dataBase64\": \"…\"}}\terwartet: {{\"dataBase64\": \"…\"}}\n"); + let (form, caps) = c.message(r#"expected {"dataBase64": "…"}"#).expect("matches"); + assert!(caps.is_empty(), "{caps:?}"); + assert_eq!(fill_once(form, &caps), r#"erwartet: {"dataBase64": "…"}"#); + assert!(c.message("expected value at line 1 column 1").is_none()); + assert!(c.message(r#"expected {"dataBase64"}"#).is_none()); + assert_eq!(exact_translation(&c, r#"expected {"dataBase64": "…"}"#).as_deref(), Some(r#"erwartet: {"dataBase64": "…"}"#)); + // An exact message is found at any length; a template is matched only up to its limit. + let long = format!("{{{{{}}}}}", "x".repeat(3000)); + let c = Catalog::parse(&format!("@msg\t{long}\tlong {{{{}}}}\n@msg\tshort {{x}}\tshort {{x}} (de)\n")); + let shown = format!("{{{}}}", "x".repeat(3000)); + assert_eq!(c.message(&shown).map(|(f, _)| f), Some("long {{}}")); + assert_eq!(exact_translation(&c, &shown).as_deref(), Some("long {}")); + assert_eq!(c.message("short y").map(|(f, _)| f), Some("short {x} (de)")); + // In the real catalog, a serde reason after the plug-in output error stays English. + assert_eq!(message(de(), "plug-in output: expected value at line 1 column 1"), "Plug-in-Ausgabe: expected value at line 1 column 1"); +} + /// `plugin.run` of an importer, an exporter or a live effect names the command that runs it with /// the JSON that picks it, and every complete-message catalog translates that error with the /// plug-in's id and the JSON kept. @@ -2502,6 +2589,10 @@ const COUNTED_STATUS_MESSAGES: &[(&str, &str)] = &[ ("Downloaded 1 file", "Downloaded 2 files"), ("Exported 1 file to /out", "Exported 2 files to /out"), ("Loaded “Tidy”: 1 step was left out", "Loaded “Tidy”: 2 steps were left out"), + ( + "Loaded “Tidy”: 1 step has an unknown command and was kept: “Export”, step 4, `example.finish`", + "Loaded “Tidy”: 2 steps have unknown commands and were kept; the first: “Export”, step 4.2, `example.finish`", + ), ]; /// The link, font, export and action set status messages name a count of 1 in the singular and other counts in @@ -4234,7 +4325,8 @@ const LATER_MESSAGE_VARIANTS: &[(&str, usize)] = &[("Disabled", 1)]; const NOT_MESSAGES: &[&str] = &["quit", "cancelled", "vc_manifest", "[{i}]{place}", ".{k}{place}"]; /// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog -/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`). +/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`), and +/// the braces of a message that is not a format string are doubled. pub fn message_literals() -> std::collections::BTreeSet { message_literals_in(MESSAGE_CRATES) } @@ -4357,16 +4449,19 @@ fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap>) -> Vec { let code = &without_comments(code); - let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..])); + let after_markers = + MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| (&code[at + marker.len()..], *marker == "#[error("))); let in_variants = ["Error::", "Fail::"].into_iter().flat_map(|enum_end| code.match_indices(enum_end)).filter_map(|(at, m)| { let rest = &code[at + m.len()..]; let (variant, args) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len())); let named = variant.starts_with(|c: char| c.is_ascii_uppercase()) && !NAME_VARIANTS.contains(&variant); let before = LATER_MESSAGE_VARIANTS.iter().find(|(v, _)| *v == variant).map_or(0, |&(_, n)| n); - if named { skip_args(args.strip_prefix('(')?, before) } else { None } + if named { skip_args(args.strip_prefix('(')?, before).map(|rest| (rest, false)) } else { None } }); let in_map_err = code.match_indices(".map_err(").flat_map(|(at, m)| { let rest = code[at + m.len()..].trim_start(); @@ -4385,13 +4480,20 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti let in_helpers = helpers.iter().flat_map(|&(call, before)| { code.match_indices(call) .filter(|&(at, _)| call.starts_with('.') || !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) - .filter_map(move |(at, _)| skip_args(&code[at + call.len()..], before)) + // `format!(` given as a helper, and an `error(` helper that is an `#[error(…)]` attribute, take a format string. + .filter_map(move |(at, _)| { + skip_args(&code[at + call.len()..], before).map(|rest| (rest, call == "format!(" || code[..at].ends_with("#["))) + }) }); let mut out = vec![]; - for rest in after_markers.chain(in_variants).chain(in_map_err).chain(in_handlers).chain(in_helpers) { + let in_map_err = in_map_err.map(|rest| (rest, false)); + let in_handlers = in_handlers.map(|rest| (rest, false)); + for (rest, takes_format) in after_markers.chain(in_variants).chain(in_map_err).chain(in_handlers).chain(in_helpers) { let rest = rest.trim_start(); let rest = rest.strip_prefix('&').map_or(rest, str::trim_start); - let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start); + let formatted = rest.strip_prefix("format!(").map(str::trim_start); + let format = takes_format || formatted.is_some(); + let rest = formatted.unwrap_or(rest); let lits = match rest.strip_prefix('"') { Some(quoted) => string_literal(quoted).into_iter().collect(), None => named_const(rest, consts).to_vec(), @@ -4400,7 +4502,7 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); let key_name = lit.len() > 1 && lit.starts_with('`') && lit.ends_with('`') && !lit.contains(' '); if has_word && !key_name && !NOT_MESSAGES.contains(&lit.as_str()) { - out.push(as_template(&lit)); + out.push(if format { as_template(&lit) } else { as_literal_key(&lit) }); } } } @@ -4595,6 +4697,12 @@ fn as_template(s: &str) -> String { out } +/// A message that is not a format string as a catalog key: each of its braces is literal, so it is +/// written `{{` or `}}`. +fn as_literal_key(s: &str) -> String { + s.replace('{', "{{").replace('}', "}}") +} + /// Render-site labels looked up in a context fall back to the plain row and then to English, and /// their templates fill in English as in any language. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0ecfe1dd0a..7120d44707 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2450,6 +2450,7 @@ shape Open Açık Proportional Metrics Orantılı Metrikler Insert Aki Left Sola Aki Ekle Insert Aki Right Sağa Aki Ekle + Tsume Tsume Digits Rakamlar Arabic Arapça Hindi Hintçe @@ -4048,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ı @@ -4240,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 @@ -4354,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 @@ -4478,7 +4494,9 @@ axis Both Her İkisi @msg no action set to save kaydedilecek eylem seti yok @msg the action set file can't be read in full eylem seti dosyası tamamen okunamıyor @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 @@ -4512,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} @@ -4519,7 +4565,7 @@ axis Both Her İkisi @msg not an HP-GL file HP-GL dosyası değil @msg not a PICT file PICT dosyası değil @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır -@msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor +@msg not an RTF file: it doesn't start with {{\\rtf bir RTF dosyası değil: {{\\rtf ile başlamıyor @msg not found bulunamadı @msg not in pattern editing mode desen düzenleme modunda değil @msg finish editing the pattern first önce deseni düzenlemeyi bitir @@ -4696,11 +4742,14 @@ 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) @msg truncated kesik @msg unknown command `{0}` bilinmeyen komut `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}” eylemi, adım {step}: bilinmeyen `{command}` komutunu çalıştırıyor @msg unknown command `{0}`; did you mean `{1}`? bilinmeyen komut `{0}`; `{1}` mi demek istediniz? @msg unknown effect `{id}` bilinmeyen etki `{id}` @msg unknown format bilinmeyen format @@ -4951,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}) @@ -4982,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 @@ -5269,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 @@ -6349,8 +6469,8 @@ axis Y Axis Y ekseni @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` dışa aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` gruplar dışında en fazla {MAX_UNGROUPED} parametre olabilir: diğerlerine bir `group` verin @msg can't encode the input girdi kodlanamıyor -@msg expected {"dataBase64"} beklenen: {"dataBase64"} -@msg expected {"objects"} beklenen: {"objects"} +@msg expected {{"dataBase64": "…"}} beklenen: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} beklenen: {{"objects": [...]}} @msg export `{name}`: {e} modül dışa aktarımı `{name}`: {e} @msg extension {e} is listed twice {e} uzantısı iki kez listelenmiş @msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} uzantısı: nokta olmadan 1-16 küçük harf ve rakam kullanın @@ -6414,11 +6534,11 @@ axis Y Axis Y ekseni @msg a painted live effect returns only `objects` boyama türü canlı efekt yalnızca `objects` döndürür @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` boyama türü canlı efektin bir parçasında yalnızca `path`, `fillRule`, `fills` ve `strokes` bulunur @msg a read-only object was left out salt okunur bir nesne atlandı -@msg a read-only object was left out: return it, as {"id"} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {"id"} olarak veya verildiği gibi döndürün +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {{"id": …}} olarak veya verildiği gibi döndürün @msg a string bir dize @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` bir konturda yalnızca `paint`, `width`, `join`, `cap` ve `miterLimit` bulunur -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} bir kontur {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} biçiminde olmalıdır -@msg an effect must be {"id", "params"?, "visible"?} bir efekt {"id", "params"?, "visible"?} biçiminde olmalıdır +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} bir kontur {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} biçiminde olmalıdır +@msg an effect must be {{"id", "params"?, "visible"?}} bir efekt {{"id", "params"?, "visible"?}} biçiminde olmalıdır @msg an effect on a fill returns only `fills` dolgudaki bir efekt yalnızca `fills` döndürür @msg an effect on a stroke returns only `strokes` konturdaki bir efekt yalnızca `strokes` döndürür @msg an object must be a JSON object bir nesne bir JSON nesnesi olmalıdır @@ -7425,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 c375bedfa2..84f296b0a6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2700,6 +2700,7 @@ mark-style Japanese Японський Proportional Metrics Пропорційні метрики Insert Aki Left Вставити акі ліворуч Insert Aki Right Вставити акі праворуч + Tsume Цуме Character Direction Напрямок символів Digits Цифри Arabic Арабські @@ -4780,6 +4781,7 @@ axis Both Обидві @msg no action set to save немає набору дій для збереження @msg the action set file can't be read in full файл набору дій неможливо прочитати повністю @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків +@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}»: {n} крок із невідомою командою збережено: «{action}», крок {step}, `{command}`|Завантажено «{name}»: {n} кроки з невідомими командами збережено; перший: «{action}», крок {step}, `{command}`|Завантажено «{name}»: {n} кроків із невідомими командами збережено; перший: «{action}», крок {step}, `{command}` @msg not an action set file це не файл набору дій @msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: довідник був би більшим за {MAX_BYTES} байтів @@ -5021,6 +5023,7 @@ axis Both Обидві @msg tool shortcuts must be a single key (optionally with Shift) поєднання інструментів має бути однією клавішею (необов’язково з Shift) @msg truncated усічено @msg unknown command `{0}` невідома команда `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` крок {step} дії «{action}» виконує невідому команду `{command}` @msg unknown command `{0}`; did you mean `{1}`? невідома команда `{0}`; можливо, ви мали на увазі `{1}`? @msg unknown effect `{id}` невідомий ефект `{id}` @msg unknown format невідомий формат @@ -5999,8 +6002,8 @@ axis Y Axis Вісь Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль експорту оголошує від 1 до {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` поза групами може бути не більше {MAX_UNGROUPED} параметрів: задайте решті `group` @msg can't encode the input не вдається закодувати вхідні дані -@msg expected {"dataBase64"} очікувалося {"dataBase64"} -@msg expected {"objects"} очікувалося {"objects"} +@msg expected {{"dataBase64": "…"}} очікувалося {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} очікувалося {{"objects": [...]}} @msg export `{name}`: {e} експорт модуля `{name}`: {e} @msg extension {e} is listed twice розширення {e} вказано двічі @msg extension {e}: use 1-16 lower-case letters and digits, without the dot розширення {e}: використовуйте 1-16 малих літер і цифр, без крапки @@ -6064,11 +6067,11 @@ axis Y Axis Вісь Y @msg a painted live effect returns only `objects` малювальний динамічний ефект повертає лише `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` частина малювального динамічного ефекту має лише `path`, `fillRule`, `fills` і `strokes` @msg a read-only object was left out пропущено об’єкт лише для читання -@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {"id"} або в тому вигляді, у якому його передано +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {{"id": …}} або в тому вигляді, у якому його передано @msg a string рядок @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` обведення має лише `paint`, `width`, `join`, `cap` і `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обведення має мати вигляд {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ефект має мати вигляд {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} обведення має мати вигляд {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ефект має мати вигляд {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` ефект на заливці повертає лише `fills` @msg an effect on a stroke returns only `strokes` ефект на обведенні повертає лише `strokes` @msg an object must be a JSON object об’єкт має бути об’єктом JSON @@ -7079,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 442690385b..e8b9e5758f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3013,6 +3013,7 @@ mark-style Japanese Kiểu Nhật Proportional Metrics Số liệu theo tỷ lệ Insert Aki Left Chèn aki bên trái Insert Aki Right Chèn aki bên phải + Tsume Tsume Proportional Lining Số tỷ lệ đứng Position: Vị trí: Default Position Vị trí mặc định @@ -5093,6 +5094,7 @@ theme Appearance Giao diện @msg no action set to save không có bộ hành động nào để lưu @msg the action set file can't be read in full không thể đọc hết tệp bộ hành động @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước +@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}` Đã tải “{name}”: giữ lại {n} bước có lệnh không xác định; bước đầu tiên: “{action}”, bước {step}, `{command}` @msg not an action set file không phải tệp bộ hành động @msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: tài liệu tham khảo sẽ lớn hơn {MAX_BYTES} byte @@ -5534,6 +5536,7 @@ theme Appearance Giao diện @msg truncated bị cắt ngắn @msg Unembedded to {_1} Đã bỏ nhúng vào {_1} @msg unknown command `{0}` lệnh không xác định `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` bước {step} của “{action}” chạy lệnh không xác định `{command}` @msg unknown command `{0}`; did you mean `{1}`? lệnh không xác định `{0}`; ý bạn là `{1}`? @msg unknown effect `{id}` hiệu ứng không xác định `{id}` @msg unknown format định dạng không xác định @@ -6000,8 +6003,8 @@ axis Y Axis Trục Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` plug-in xuất khai báo từ 1 đến {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` tối đa {MAX_UNGROUPED} tham số nằm ngoài nhóm: hãy đặt `group` cho các tham số còn lại @msg can't encode the input không thể mã hóa dữ liệu đầu vào -@msg expected {"dataBase64"} cần có {"dataBase64"} -@msg expected {"objects"} cần có {"objects"} +@msg expected {{"dataBase64": "…"}} cần có {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} cần có {{"objects": [...]}} @msg export `{name}`: {e} export `{name}` của mô-đun: {e} @msg extension {e} is listed twice phần mở rộng {e} được liệt kê hai lần @msg extension {e}: use 1-16 lower-case letters and digits, without the dot phần mở rộng {e}: dùng 1-16 chữ thường và chữ số, không có dấu chấm @@ -6065,11 +6068,11 @@ axis Y Axis Trục Y @msg a painted live effect returns only `objects` hiệu ứng động kiểu tô chỉ trả về `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` một phần của hiệu ứng động kiểu tô chỉ có `path`, `fillRule`, `fills` và `strokes` @msg a read-only object was left out một đối tượng chỉ đọc đã bị bỏ sót -@msg a read-only object was left out: return it, as {"id"} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {"id"} hoặc như khi được nhận +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {{"id": …}} hoặc như khi được nhận @msg a string một chuỗi @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` nét chỉ có `paint`, `width`, `join`, `cap` và `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} nét phải có dạng {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} hiệu ứng phải có dạng {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} nét phải có dạng {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} hiệu ứng phải có dạng {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` hiệu ứng trên phần tô chỉ trả về `fills` @msg an effect on a stroke returns only `strokes` hiệu ứng trên nét chỉ trả về `strokes` @msg an object must be a JSON object đối tượng phải là một đối tượng JSON @@ -7080,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 810b1b4463..719968086f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2563,6 +2563,7 @@ font-script Japanese 汉字 Proportional Metrics 比例度量 Insert Aki Left 在前方插入空白 Insert Aki Right 在后方插入空白 + Tsume 比例间距 Character Direction 字符方向 Digits 数字 Arabic 阿拉伯数字 @@ -4408,6 +4409,7 @@ imageTrace Outline 轮廓 @msg objects can't be nested more than 32 deep 对象的嵌套不能超过 32 层 @msg the SVG's elements are nested more than {0} deep SVG 的元素嵌套超过 {0} 层 @msg unknown command `{0}` 未知命令 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}”的步骤 {step} 运行未知命令 `{command}` @msg unknown command `{0}`; did you mean `{1}`? 未知命令 `{0}`;您是否想输入 `{1}`? @msg `{what}` closes nothing `{what}` 没有可结束的内容 @msg `{what}` closes something it didn't open `{what}` 结束了并非由它开始的内容 @@ -4743,6 +4745,7 @@ imageTrace Outline 轮廓 @msg no action set to save 没有要存储的动作组 @msg the action set file can't be read in full 无法完整读取动作组文件 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤 +@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}”:保留了 {n} 个含未知命令的步骤;第一个:“{action}”,步骤 {step},`{command}` @msg not an action set file 不是动作组文件 @msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:参考将大于 {MAX_BYTES} 字节 @@ -6232,8 +6235,8 @@ axis Y Axis Y 轴 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 导出增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 组外最多 {MAX_UNGROUPED} 个参数:请为其余参数指定 `group` @msg can't encode the input 无法编码输入 -@msg expected {"dataBase64"} 应为 {"dataBase64"} -@msg expected {"objects"} 应为 {"objects"} +@msg expected {{"dataBase64": "…"}} 应为 {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} 应为 {{"objects": [...]}} @msg export `{name}`: {e} 模块导出 `{name}`:{e} @msg extension {e} is listed twice 扩展名 {e} 列出了两次 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 扩展名 {e}:请使用 1-16 个小写字母和数字,不带点 @@ -6297,11 +6300,11 @@ axis Y Axis Y 轴 @msg a painted live effect returns only `objects` 绘制型实时效果只返回 `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 绘制型实时效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` @msg a read-only object was left out 漏掉了一个只读对象 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一个只读对象:请以单独的 {"id"} 或原样返回它 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 漏掉了一个只读对象:请以单独的 {{"id": …}} 或原样返回它 @msg a string 字符串 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 描边只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 描边必须是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 -@msg an effect must be {"id", "params"?, "visible"?} 效果必须是 {"id", "params"?, "visible"?} 形式 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 描边必须是 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 形式 +@msg an effect must be {{"id", "params"?, "visible"?}} 效果必须是 {{"id", "params"?, "visible"?}} 形式 @msg an effect on a fill returns only `fills` 填色上的效果只返回 `fills` @msg an effect on a stroke returns only `strokes` 描边上的效果只返回 `strokes` @msg an object must be a JSON object 对象必须是 JSON 对象 @@ -7308,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 19c572e3b3..121a8b8c02 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7,6 +7,7 @@ # the translation is a single string (see `plural` in i18n.rs) # anything else a disambiguating context passed to `tr_ctx` # escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# In @msg rows a literal brace is written {{ or }}, as in a Rust format string. # lines starting with # and blank lines are ignored. # # Clean-room: every entry was written from the plain meaning of the English label using the @@ -2119,6 +2120,7 @@ file-filter Templates 範本 @msg objects can't be nested more than 32 deep 物件的巢狀層級不能超過 32 層 @msg the SVG's elements are nested more than {0} deep SVG 的元素巢狀超過 {0} 層 @msg unknown command `{0}` 未知的指令 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` 「{action}」的步驟 {step} 執行未知的指令 `{command}` @msg unknown command `{0}`; did you mean `{1}`? 未知的指令 `{0}`;您是指 `{1}` 嗎? @msg `{what}` closes nothing `{what}` 沒有可結束的內容 @msg `{what}` closes something it didn't open `{what}` 結束了並非由它開始的內容 @@ -2457,6 +2459,7 @@ file-filter Templates 範本 @msg no action set to save 沒有要儲存的動作組 @msg the action set file can't be read in full 無法完整讀取動作組檔案 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟 +@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}」:保留了 {n} 個含未知指令的步驟;第一個:「{action}」,步驟 {step},`{command}` @msg not an action set file 不是動作組檔案 @msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:參考將大於 {MAX_BYTES} 位元組 @@ -3779,6 +3782,7 @@ font-script Japanese 漢字 Proportional Metrics 比例度量 Insert Aki Left 在前方插入空白 Insert Aki Right 在後方插入空白 + Tsume 比例間距 Character Direction 字元方向 Digits 數字 Arabic 阿拉伯數字 @@ -6038,8 +6042,8 @@ axis Y Axis Y 軸 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 匯出增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 群組外最多 {MAX_UNGROUPED} 個參數:請為其餘參數指定 `group` @msg can't encode the input 無法編碼輸入 -@msg expected {"dataBase64"} 應為 {"dataBase64"} -@msg expected {"objects"} 應為 {"objects"} +@msg expected {{"dataBase64": "…"}} 應為 {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} 應為 {{"objects": [...]}} @msg export `{name}`: {e} 模組匯出 `{name}`:{e} @msg extension {e} is listed twice 副檔名 {e} 列出了兩次 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 副檔名 {e}:請使用 1-16 個小寫字母和數字,不含點 @@ -6103,11 +6107,11 @@ axis Y Axis Y 軸 @msg a painted live effect returns only `objects` 繪製型即時效果只傳回 `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 繪製型即時效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` @msg a read-only object was left out 漏掉了一個唯讀物件 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {"id"} 或原樣傳回它 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {{"id": …}} 或原樣傳回它 @msg a string 字串 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 筆畫只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 筆畫必須是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 -@msg an effect must be {"id", "params"?, "visible"?} 效果必須是 {"id", "params"?, "visible"?} 形式 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 筆畫必須是 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 形式 +@msg an effect must be {{"id", "params"?, "visible"?}} 效果必須是 {{"id", "params"?, "visible"?}} 形式 @msg an effect on a fill returns only `fills` 填色上的效果只傳回 `fills` @msg an effect on a stroke returns only `strokes` 筆畫上的效果只傳回 `strokes` @msg an object must be a JSON object 物件必須是 JSON 物件 @@ -7115,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 重新整理延後的效果 @@ -7145,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/io.rs b/crates/ui-egui/src/io.rs index eec2a66831..ee083d7d44 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -23,7 +23,8 @@ mod tests_read_only_save; pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; pub(crate) use export::{ - export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, + EXPORT_SELECTION, export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, + selection_path, }; pub(crate) use open::report_installed; pub(crate) use open::{EXPORT_PRESETS, import_presets, open_notes, open_with_on, opened, opened_after_notice, show_opened}; @@ -436,7 +437,9 @@ mod tests { app.session.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); app.services.pick_save = Some(Box::new(|_: &FilePick| Some("/tmp/sel.svg".into()))); - assert_eq!(app.run("document.exportSelection", json!({})).unwrap()["path"], "/tmp/sel.svg"); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"), "the picked format's options follow"); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["path"], "/tmp/sel.svg"); app.run("document.exportSelection", json!({"path": "/tmp/sel.png"})).unwrap(); let w = written.borrow(); assert!(String::from_utf8_lossy(&w[0].1).contains(" Result<(bool, Value), String> { + let mut p = params.clone(); + let Some(o) = p.as_object_mut() else { return Ok((false, p)) }; + match o.remove(EXPORT_SELECTION) { + None | Some(Value::Null | Value::Bool(false)) => return Ok((false, p)), + Some(Value::Bool(true)) => {} + Some(v) => return Err(format!("{EXPORT_SELECTION} must be true or false, not {v}")), + } + let strip = |o: &mut serde_json::Map| { + for k in fileio::ARTBOARD_PARAMS.iter().chain(&["useArtboards", "selectedOnly"]) { + o.remove(*k); + } + }; + strip(o); + if let Some(svg) = o.get_mut("svg").and_then(Value::as_object_mut) { + strip(svg); + } + Ok((true, p)) +} + /// File → Cancel Export (the progress dialog's Cancel): every export running in the background /// stops and writes nothing more → `{cancelled: n}`. pub fn cancel_exports(app: &mut VectorcraftApp) -> Value { @@ -29,11 +56,16 @@ pub fn hide_exports(app: &mut VectorcraftApp) -> Value { /// options…): every file it writes (one per artboard, linked images) goes next to `path`. The /// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are /// several). When automation asked for the export, `warnings` also name each option the written -/// format doesn't read, as `document.export`'s do. +/// format doesn't read, as `document.export`'s do. With [`EXPORT_SELECTION`] the file holds the +/// selection alone, cropped to it, as `document.exportSelection` writes it. With Appearance of +/// Black › Printing / Exporting set to rich black, RGB files draw a CMYK black as RGB black, as in +/// `document.export`. pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { // Options given as anything but an object are none: the path and format still reach the // encoder (an exporter plug-in is told the file it writes). let params = &if params.is_object() { params.clone() } else { json!({}) }; + let (selection, params) = take_selection(params)?; + let params = ¶ms; let f = fileio::writable_format(format, path.as_deref())?; // A Shape Builder merge still being computed for a click is exported with the document. let r = app.session.finish_tool_edit(); @@ -42,9 +74,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option st.doc.clone(), - std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + let doc = if selection { + std::sync::Arc::new(fileio::selection_doc(st, "document.exportSelection", f.id == "txt").map_err(|e| e.to_string())?.0) + } else { + match fileio::export_doc(st, f, params).map_err(|e| e.to_string())? { + std::borrow::Cow::Borrowed(_) => st.doc.clone(), + std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + } }; // A PDF shows its title in the viewer's title bar only when it is the document's own, not the // file name it was titled after, as `document.export` writes it. @@ -55,6 +91,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, path: Option, path: Option Result, String> { bytes.ok_or_else(|| "no data".into()) } +/// The file an Export Selection writes, picked in a save panel (the web: the suggested name): a +/// typed extension of any format is kept, and names the format. +pub(crate) fn selection_path(app: &mut VectorcraftApp) -> Result { + target_path_keeping(app, None, "png", |_| true) +} + /// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to /// that path (else a picked or suggested name) → (path, the command's result without the data). /// The command runs before a path is asked for, so bad params never open a save dialog, except diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 5e05732b62..7b7f7a6b21 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -171,6 +171,8 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::export_selection(app), "document.exportSelection" => { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 255d0836b5..52c8c2e7bf 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -261,7 +261,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "type.convertListToText", "Convert to Text", "", - "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", + "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs, in every frame of a threaded story (or of those the Type tool's selection touches), become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", ), ( "align.horizontalLeft", @@ -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", @@ -1265,7 +1277,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth, the canvas context menu's Smooth… and the Contextual Task Bar's Smooth open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", @@ -1331,7 +1343,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.exportAs", "Export As…", "", - "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", ), ( "ui.fileInfoDialog", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 71019385b6..bbe91da5a6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -54,7 +54,7 @@ pub mod variables; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{LiveCorners, Node, StrokeLayer}; +use vectorcraft_doc::{LiveCorners, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_engine::inspect::StrokeMixed; use vectorcraft_geom::shapes::CornerKind; @@ -322,11 +322,13 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { } /// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per -/// document revision and Appearance panel item. +/// document revision, Appearance panel item and picked gradient mesh points (picking points +/// changes no revision). pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) { + type Key = (u64, u64, Option, Vec<(NodeId, Vec)>); let Some(st) = app.session.active() else { return (false, false) }; - let key = (st.uid, st.revision, app.session.appearance_item()); - match pstate::), (bool, bool))>>(ctx, "proxy-mixed") { + let key: Key = (st.uid, st.revision, app.session.appearance_item(), app.session.fill_mesh_points()); + match pstate::>(ctx, "proxy-mixed") { Some((k, mixed)) if k == key => mixed, _ => { let mixed = app.session.proxy_mixed(); @@ -365,13 +367,23 @@ pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeM } /// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance -/// panel's active item's, else the first selected object's. +/// panel's active item's, else the first picked gradient mesh point's opacity (which +/// `transparency.set` changes then) with its object's blend mode, else the first selected object's. pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> { let n = first_node(app)?; - Some(match app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { - Some(it) => (it.opacity(), it.blend()), - None => (n.opacity, n.blend), - }) + if let Some(it) = app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { + return Some((it.opacity(), it.blend())); + } + let picked = app.session.mesh_points_picked().and_then(|points| { + let (id, v) = points.first()?; + let d = &app.session.active()?.doc; + let m = match &d.node(*id)?.kind { + NodeKind::Mesh(m) => m, + _ => return None, + }; + Some((m.points.get(*v.first()?)?.opacity, d.node(*id)?.blend)) + }); + Some(picked.unwrap_or((n.opacity, n.blend))) } /// Is the active proxy "?" (the selected objects' paints differ)? @@ -529,6 +541,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 +567,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 +708,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 20a57b7d63..4bab22d52e 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -85,6 +85,70 @@ impl Action { }) .collect() } + + /// The steps whose command `known` does not hold, in order, as (step index, the index of the + /// command in a `command.batch` step, command id). A `command.batch` step's own commands are + /// checked one by one, as playback runs them ([`Action::included_commands`]); a command of one + /// with no command id has the id "". With `included_only`, excluded steps are passed over. + pub(crate) fn unknown_commands<'a>( + &'a self, + known: &'a KnownCommands, + included_only: bool, + ) -> impl Iterator, &'a str)> + 'a { + self.steps.iter().enumerate().filter(move |(i, _)| !(included_only && self.excluded_steps.contains(i))).flat_map( + |(i, (command, params))| -> Vec<(usize, Option, &'a str)> { + match params.get("commands").and_then(Value::as_array) { + Some(steps) if command == "command.batch" => steps + .iter() + .enumerate() + .map(|(j, step)| (i, Some(j), step.get("command").and_then(Value::as_str).unwrap_or_default())) + .filter(|(_, _, id)| !known.has(id)) + .collect(), + _ if known.has(command) => vec![], + _ => vec![(i, None, command.as_str())], + } + }, + ) + } +} + +/// The command ids a step of an action can run now: the built-in engine commands, the commands the +/// installed plug-ins add, the View menu commands the app plays itself ([`crate::menus::plays_in_app`]) +/// and stops ([`STOP_STEP`]). +pub(crate) struct KnownCommands(std::collections::HashSet<&'static str>); + +impl KnownCommands { + /// The command ids known when it is called: a plug-in installed or removed later is not in it. + pub(crate) fn now() -> Self { + let engine = vectorcraft_engine::command_specs().iter().map(|c| c.id); + let plugins = vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| c.id); + Self(engine.chain(plugins).chain([STOP_STEP]).collect()) + } + + /// Whether a step can run command `id`. + pub(crate) fn has(&self, id: &str) -> bool { + self.0.contains(id) || crate::menus::plays_in_app(id) + } +} + +/// The one-based place of a step that [`Action::unknown_commands`] gives: `3` for the third step, +/// `3.2` for the second command of a `command.batch` third step. +pub(crate) fn step_place(step: usize, in_batch: Option) -> String { + match in_batch { + Some(j) => format!("{}.{}", step + 1, j + 1), + None => (step + 1).to_string(), + } +} + +/// `text` as a status message shows it: control characters become `�`, and text past 80 +/// characters ends in `…`. +pub(crate) fn shown_text(text: &str) -> String { + const MAX: usize = 80; + let mut shown: String = text.chars().take(MAX).map(|c| if c.is_control() { '\u{fffd}' } else { c }).collect(); + if text.chars().nth(MAX).is_some() { + shown.push('…'); + } + shown } /// An action id no other action in this run has. @@ -257,7 +321,10 @@ pub fn default_sets() -> Vec { /// Accelerated runs every included step at once as a single undoable batch → what `command.batch` /// returns, {results}. Step by Step and Pause For run the first step now, each as its own undo step, /// and leave the rest to [`tick_playback`] → {results: [the first step's result], pending: the steps -/// left}. While a paced playback is in progress, another action does not play. +/// left}. While a paced playback is in progress, another action does not play. An action with an +/// included step whose command is not known now ([`KnownCommands`]), a plug-in's command after the +/// plug-in was removed for one, does not play: the error names the first such step and its command, +/// and no step runs. An excluded step is not checked. /// /// An action with an included stop ([`STOP_STEP`]) plays up to the stop and waits there at any speed /// ([`continue_playback`]). At Accelerated, the steps between two stops are one undo step. → {results: @@ -275,6 +342,13 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result Result { /// Actions… would write as a file Load Actions… refuses (larger than 8 MiB, as a file with less /// white space or a name that gets a number can give) is refused too, and no set is added. /// +/// A step whose command is not known now ([`super::KnownCommands`]), excluded or not, and a command +/// of a `command.batch` step that is not, still loads as it is. The answer then also lists them, +/// and the status bar names the first and how many there are → `{…, unknownCommands: [{action, +/// actionIndex, step, stepIndex, batchStep?, command}] (at most 1,000), unknownCount: n}`. +/// /// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its /// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), /// with no params; a step that is off in the file is excluded from playback. A set with no name is @@ -537,7 +543,47 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { set.name = free_set_name(&app.ui.action_sets, &set.name); // A number added to the name can make a file at the limit larger than it. super::edit_keeps_set_loadable(app, None, &set)?; + let (unknown, unknown_count, first) = unknown_commands(&set); let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); - Ok(json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions })) + let mut answer = json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions }); + if let Some((action, step, command)) = first { + app.status(crate::i18n::msg_n( + unknown_count as u64, + "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", &super::shown_text(&name)), + ("action", &super::shown_text(&action)), + ("step", &step), + ("command", &super::shown_text(&command)), + ], + )); + answer["unknownCommands"] = json!(unknown); + answer["unknownCount"] = json!(unknown_count); + } + Ok(answer) +} + +/// The steps of `set` whose command is not known now ([`super::KnownCommands`]), excluded steps +/// included: the first [`MAX_SKIPPED`] as `[{action, actionIndex, step, stepIndex, batchStep?, +/// command}]` (`step` and `batchStep` count from 1, the indexes from 0), how many there are, and the +/// first one's action name, place ([`super::step_place`]) and command. +fn unknown_commands(set: &ActionSet) -> (Vec, usize, Option<(String, String, String)>) { + let known = super::KnownCommands::now(); + let (mut listed, mut count, mut first) = (vec![], 0usize, None); + for (a, action) in set.actions.iter().enumerate() { + for (step, in_batch, command) in action.unknown_commands(&known, false) { + count += 1; + first.get_or_insert_with(|| (action.name.clone(), super::step_place(step, in_batch), command.to_string())); + if listed.len() < MAX_SKIPPED { + let mut entry = json!({ "action": action.name, "actionIndex": a, "step": step + 1, "stepIndex": step, "command": command }); + if let Some(j) = in_batch { + entry["batchStep"] = json!(j + 1); + } + listed.push(entry); + } + } + } + (listed, count, first) } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 0cb47e6542..d003fbcbce 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -26,6 +26,8 @@ pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0 /// Insert Aki Left and Right presets, in thousandths of an em: none, a quarter, half and a whole /// full-width space. pub const AKI_PRESETS: [f64; 4] = [0.0, 250.0, 500.0, 1000.0]; +/// Tsume presets, in percent. +pub const TSUME_PRESETS: [f64; 5] = [0.0, 25.0, 50.0, 75.0, 100.0]; /// The stored character style: the selected range's (or the caret's) while the Type /// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph @@ -846,7 +848,7 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { /// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking, /// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the /// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown), and with Show East -/// Asian Options on a row with Insert Aki Left and Right. With Show Font +/// Asian Options on a row with Insert Aki Left and Right and a row with Tsume. With Show Font /// Height Options on, a row under Font Size holds the Set Font Height Reference dropdown. The /// Properties panel shows its first rows. pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) { @@ -942,6 +944,14 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & }); } ui.end_row(); + // Tsume: the share of the blank space beside each character's ink taken away. + cell(ui, "→A←", tl!("Tsume"), |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-tsume", shared(app, |s| s.tsume), "%", 1, fw, 1.0, 0.0, &TSUME_PRESETS) { + // The field stops at the largest value the command takes. + format(app, json!({ "tsume": v.min(vectorcraft_doc::text::MAX_TSUME) })); + } + }); + ui.end_row(); } }); } @@ -1686,7 +1696,7 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline /// character alignment, Proportional Metrics, Tate-chu-yoko, Warichu, Standard Vertical Roman Alignment and /// No Break off, Tate-chu-yoko Settings Up/Down and Left/Right 0, the default Warichu Settings (2 lines, no -/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, the Default character direction, +/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, Tsume 0, the Default character direction, /// Kashidas on, the Default digits and the Em Box font height reference, whether or not Show East Asian /// Options, Show Indic Options and Show Font Height Options are on. Inside an undo group that is /// already open, the reset is part of that group's step. @@ -1706,7 +1716,7 @@ fn reset_panel(app: &mut VectorcraftApp) { "allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align, "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "tcyUpDown": d.tcy_up_down, "tcyLeftRight": d.tcy_left_right, "akiLeft": d.aki_left, "akiRight": d.aki_right, - "warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale, + "tsume": d.tsume, "warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale, "warichuAlign": d.warichu_align, "standardVerticalRoman": d.standard_vertical_roman, "noBreak": d.no_break, "charDirection": "default", "kashidas": d.kashidas, "digits": d.digits, "fontHeightReference": d.font_height_reference diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 56472c59b0..cdeafd2b00 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -780,6 +780,41 @@ fn insert_aki_fields_show_with_the_east_asian_options() { assert!(left(&app).iter().all(|v| *v == 1000.0), "{:?}", left(&app)); } +/// Tsume is a field of the panel with the East Asian options only, and shows the selected +/// characters' value, blank where they differ. A value typed past 100 sets 100. +#[test] +fn tsume_field_shows_with_the_east_asian_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("text.setFormat", json!({"tsume": 37.5})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(!text.contains("→A←") && !text.contains("37.5"), "{text}"); + app.session.prefs.show_east_asian_options = true; + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("→A←") && text.contains("37.5"), "{text}"); + // The characters differ: the field is blank. + app.run("text.setRangeStyle", json!({"id": id, "start": 3, "end": 12, "tsume": 62.5})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(!text.contains("37.5") && !text.contains("62.5"), "{text}"); + app.run("text.setFormat", json!({"tsume": 62.5})).unwrap(); + use super::super::tests_appearance::{click, frame_events, text_rect}; + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "62.5%").center(), show); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], show); + frame_events(&ctx, &mut app, vec![egui::Event::Text("150".into())], show); + frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + let tsume = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.tsume).collect::>(), + _ => panic!("text"), + }; + assert!(tsume(&app).iter().all(|v| *v == 100.0), "{:?}", tsume(&app)); +} + /// The panel menu's Show Font Height Options shows the Set Font Height Reference dropdown, /// which shows the selected type's reference and sets the one picked (one undo step); Hide /// Font Height Options hides it again. @@ -1444,6 +1479,7 @@ fn reset_panel_resets_every_attribute_but_the_font() { s.tcy_left_right != d.tcy_left_right, s.aki_left != d.aki_left, s.aki_right != d.aki_right, + s.tsume != d.tsume, s.standard_vertical_roman != d.standard_vertical_roman, s.no_break != d.no_break, s.char_direction != d.char_direction, @@ -1458,16 +1494,16 @@ fn reset_panel_resets_every_attribute_but_the_font() { "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "tcyUpDown": 2, "tcyLeftRight": -3, - "akiLeft": 250, "akiRight": 500, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight" + "akiLeft": 250, "akiRight": 500, "tsume": 50, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight" }), ) .unwrap(); }; let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 24]); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 25]); custom(&mut app); let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 24]); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 25]); app.run("select.set", json!({"ids": [id]})).unwrap(); reset_panel(&mut app); assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index cd7b88cdd0..bd605eef01 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -949,6 +949,60 @@ fn a_mixed_selection_has_no_colour_to_edit() { assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false), "a new revision recomputes it"); } +/// With gradient mesh points picked, the panel edits and shows their color (a solid fill +/// recolors just them), not the mesh object's fill, so the sliders keep the value they set, +/// through a drag too; "?" when the picked points differ, which picking alone (no new revision) +/// updates. The Control bar's opacity is the picked point's. +#[test] +fn picked_mesh_points_are_the_color_the_panel_shows_and_edits() { + use vectorcraft_doc::NodeKind; + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().selection.objects[0]; + app.run("paint.setFill", json!({"color": "#ff8500"})).unwrap(); + app.run("object.mesh.create", json!({"rows": 2, "cols": 2})).unwrap(); + app.run("object.mesh.setPointColor", json!({"id": id.0, "index": 0, "color": "#0000ff", "opacity": 0.5})).unwrap(); + let v = ViewInfo::default(); + app.session.select_tool("mesh", v).unwrap(); + let click = |app: &mut VectorcraftApp, x: f64, y: f64, shift: bool| { + for k in [PointerKind::Down, PointerKind::Up] { + let mut ev = PointerEvent::new(k, x, y); + ev.mods.shift = shift; + app.session.pointer(&ev, v).unwrap(); + } + }; + let picked = |app: &VectorcraftApp| app.session.fill_mesh_points().into_iter().flat_map(|(_, v)| v).collect::>(); + // The top-left corner point (index 0). + click(&mut app, 10.0, 10.0, false); + assert_eq!(picked(&app), vec![0], "the click picks the top-left point"); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false)); + let tgt = target_with(&app, false); + assert_eq!(tgt.color().map(|c| c.to_hex()), Some("#0000ff".into()), "the picked point's color, not the object's fill"); + assert_eq!(crate::panels::current_transparency(&app).map(|t| t.0), Some(0.5), "the picked point's opacity"); + // A slider drag: each step shows the color it set, and the release keeps it. + for c in [Color::rgb8(250, 0, 120), Color::rgb8(240, 10, 140)] { + let tgt = target_with(&app, false); + apply(&mut app, &tgt, c, Live::Dragging, false); + assert_eq!(target_with(&app, false).color(), Some(c), "the drag shows the color it set"); + } + let orange = Color::rgb8(255, 128, 0); + let tgt = target_with(&app, false); + apply(&mut app, &tgt, orange, Live::Released, false); + assert_eq!(target_with(&app, false).color(), Some(orange), "the panel keeps the color it set"); + let NodeKind::Mesh(m) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("still a gradient mesh") }; + assert_eq!(m.points[0].color, orange); + let middle = m.idx(0, 1); + assert_ne!(m.points[middle].color, orange, "only the picked point"); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false)); + // Shift-click the top middle point (still the object's color): the two differ. + click(&mut app, 35.0, 10.0, true); + assert_eq!(picked(&app), vec![0, middle], "the Shift-click adds the top middle point"); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (true, false), "picking recomputes it"); +} + #[test] fn rgb_roundtrip() { let c = Color::rgb8(230, 120, 40); 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/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 3b55d34e11..9c10281783 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -30,6 +30,7 @@ use std::collections::HashMap; use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::Paint; +use vectorcraft_doc::resample::{Resample, fitted}; use vectorcraft_engine::cmd::library::{ItemKind, LIBRARY_EXT, LIBRARY_EXTS, Library, LibraryGraphic}; use crate::theme::Tokens; @@ -831,8 +832,7 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32) } let tex = vectorcraft_format::base64_decode(png).and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()).map(|img| { - let img = if img.width().max(img.height()) > px { img.resize(px, px, image::imageops::FilterType::Triangle) } else { img }; - let rgba = img.to_rgba8(); + let rgba = shrunk_thumbnail(img, px); let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); ctx.load_texture(format!("library-{library}-{item}-{px}"), image, egui::TextureOptions::LINEAR) }); @@ -846,6 +846,13 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32) tex } +/// Thumbnail `img` shrunk smoothly to at most `px` pixels a side, its colors weighted by its alpha +/// so that clear pixels don't darken the edges of the art, as RGBA. +fn shrunk_thumbnail(img: image::DynamicImage, px: u32) -> image::RgbaImage { + let img = if img.width().max(img.height()) > px { fitted(img, px, px, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img }; + img.into_rgba8() +} + /// Graphic `item` of `library`'s thumbnail in `r` (the chip a dragged graphic shows at the pointer). pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item: &str) { if let Some(g) = app.session.libraries.get(library).and_then(|l| l.graphics.iter().find(|g| g.id == item)) { diff --git a/crates/ui-egui/src/panels/libraries/tests.rs b/crates/ui-egui/src/panels/libraries/tests.rs index 931a91a444..00ff1bf2cd 100644 --- a/crates/ui-egui/src/panels/libraries/tests.rs +++ b/crates/ui-egui/src/panels/libraries/tests.rs @@ -525,3 +525,16 @@ fn libraries_are_exported_and_imported_from_the_panel_menu() { assert_eq!(names, ["Brand", "Brand (2)"]); assert_eq!(app.ui.status, "Imported library Brand (2)"); } + +/// A graphic's thumbnail shrunk for its tile keeps the color of the art beside clear pixels: the +/// clear black beside it does not darken it. +#[test] +fn a_shrunk_thumbnail_keeps_its_color_beside_clear_pixels() { + let img = image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let small = shrunk_thumbnail(image::DynamicImage::ImageRgba8(img), 16); + assert_eq!(small.dimensions(), (16, 1)); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for p in small.pixels() { + assert!(p[3] > 0 && [200, 30, 30].iter().zip(p.0).all(|(&want, c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}"); + } +} 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/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 64daf1ac76..5718aea0ab 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -1,6 +1,6 @@ //! OpenType panel: per-character OpenType features (ligatures, alternates, swashes, small caps, -//! ordinals, fractions, figure style, position) for the selected text or the Type tool's selected -//! characters. +//! ordinals, fractions, figure style, position) for the selected text (every frame of each +//! selected threaded story) or the Type tool's selected characters. use egui::Ui; use serde_json::json; @@ -112,6 +112,22 @@ fn position_label(shown: Option) -> &'static str { shown.and_then(|i| POSITIONS.get(i).copied()).unwrap_or("Mixed") } +/// The Figure menu entry for the characters the panel acts on, or `None` when they differ. +fn shown_figure(app: &VectorcraftApp) -> Option { + shared(app, |s| figure(&OtFeatures::from_tags(s.features.iter().map(String::as_str)))) +} + +/// Whether `flag` is on for every character the panel acts on (`Some(true)`), for none of them +/// (`Some(false)`), or only for some (`None`). +fn shown_flag(app: &VectorcraftApp, flag: Flag) -> Option { + shared(app, |s| flag.get(&OtFeatures::from_tags(s.features.iter().map(String::as_str)))) +} + +/// The Figure menu's text for entry `shown`: Mixed when the characters differ. +fn figure_label(shown: Option) -> &'static str { + shown.and_then(|i| FIGURES.get(i).copied()).unwrap_or("Mixed") +} + /// A change one of the panel's controls makes to the features of each character it acts on. #[derive(Clone, Copy)] enum Change { @@ -161,7 +177,8 @@ fn set(app: &mut VectorcraftApp, f: OtFeatures) { } /// Send `tags` as the `key` param (`features` or `featureChanges`) for the Type tool's range or -/// caret, or else for the selected type. +/// caret, or else for every frame of the selected stories +/// ([`super::character::selected_stories`]). fn run_features(app: &mut VectorcraftApp, key: &str, tags: Vec) { // After Select All in threaded text: every frame of the story. let story = super::character::text_story(app).and_then(|story| story.first().copied()); @@ -169,7 +186,11 @@ fn run_features(app: &mut VectorcraftApp, key: &str, tags: Vec) { (Some(_), Some(first)) => ("text.setRangeStyle", json!({ "id": first.0, "story": true })), (Some((id, a, b)), None) if b > a => ("text.setRangeStyle", json!({ "id": id.0, "start": a, "end": b })), (Some((id, _, _)), None) => ("text.setRangeStyle", json!({ "id": id.0 })), - (None, _) => ("text.setStyle", json!({})), + (None, _) => { + let mut p = json!({}); + super::character::target_selected_stories(app, &mut p); + ("text.setStyle", p) + } }; p[key] = json!(tags); if let Err(e) = app.run(cmd, p) { @@ -184,10 +205,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; let cur = OtFeatures::from_tags(st.features.iter().map(String::as_str)); let mut change = None; + let fig = shown_figure(app); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Figure:")); - let fig = figure(&cur); - if let Some(i) = widgets::dropdown(ui, "ot-figure", FIGURES[fig], &FIGURES, 170.0) { + if let Some(i) = widgets::dropdown(ui, "ot-figure", figure_label(fig), &FIGURES, 170.0) { change = Some(Change::Figure(i)); } }); @@ -199,26 +220,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); widgets::divider(ui); - let mut next = cur; - if let Some(c) = change { - c.apply(&mut next); - } + // A row whose characters differ shows a dash, and a click turns the feature on for all of them. for (label, flag) in rows() { - let on = flag.get(&next); - if widgets::check(ui, label, on, true) { - change = Some(Change::Toggle(flag, !on)); + let shown = shown_flag(app, flag); + if widgets::check3(ui, label, shown, true) { + change = Some(Change::Toggle(flag, shown != Some(true))); } } // The font, not the text, decides which numbered sets can be offered. Each set is an // independent feature, so the menu permits several at once. A font's own label wins over - // the numbered fallback. See the public Stylistic Sets help page. - let sets = offered_sets(&st); + // the numbered fallback. See the public Stylistic Sets help page. A set is checked when every + // character has it. + let sets: Vec<(StylisticSet, Flag, bool)> = offered_sets(&st) + .into_iter() + .map(|set| { + let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); + let on = shown_flag(app, flag) == Some(true); + (set, flag, on) + }) + .collect(); ui.add_enabled_ui(!sets.is_empty(), |ui| { ui.menu_button(tl!("Stylistic Sets"), |ui| { widgets::menu_scroll(ui, |ui| { - for set in &sets { - let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); - let on = flag.get(&next); + for (set, flag, on) in &sets { + let (flag, on) = (*flag, *on); if widgets::check_name(ui, &set_label(set), on) { change = Some(Change::Toggle(flag, !on)); } @@ -481,6 +506,90 @@ mod tests { } } + /// The features of every run of `id`, each as its tags. + fn frame_features(app: &VectorcraftApp, id: u64) -> Vec> { + match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.features.clone()).collect(), + _ => panic!("not text"), + } + } + + /// With the Selection tool a selected frame of threaded text stands for its whole story: the + /// Figure and Position menus show Mixed when a locked or hidden frame differs, and a choice + /// reaches every frame in one undo step, each keeping its other features, while other type keeps + /// its own. A story selected through two frames and a group is changed once. + #[test] + fn selection_tool_features_reach_every_frame_of_the_story() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog 1234.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other 12", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("text.setStyle", json!({"ids": [c], "features": ["onum", "smcp", "sups", "-liga"]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + for id in [a, b, c] { + assert!(!frame_features(&app, id).is_empty(), "frame {id} holds text"); + } + assert_eq!(shown_figure(&app), None, "the hidden frame's figures differ"); + assert_eq!(figure_label(shown_figure(&app)), "Mixed"); + assert_eq!(shown_position(&app), None, "the hidden frame's position differs"); + let hidden = frame_features(&app, c); + let has = |app: &VectorcraftApp, id: u64, tag: &str| frame_features(app, id).iter().all(|f| f.iter().any(|t| t == tag)); + + let undo = undo_steps(&app); + edit(&mut app, Change::Figure(1)); + assert_eq!(undo_steps(&app), undo + 1, "one undo step"); + assert_eq!(shown_figure(&app), Some(1)); + for id in [a, b] { + assert!(frame_features(&app, id).iter().all(|f| *f == ["tnum"]), "frame {id}: {:?}", frame_features(&app, id)); + } + assert!( + ["smcp", "sups", "-liga", "tnum"].iter().all(|t| has(&app, c, t)) + && frame_features(&app, c).iter().all(|f| !f.iter().any(|t| t == "onum")), + "{:?}", + frame_features(&app, c) + ); + assert!(frame_features(&app, other).iter().all(Vec::is_empty), "other type keeps its features"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(frame_features(&app, c), hidden, "undo restores the hidden frame"); + + // Standard Ligatures is off in the hidden frame only: its row shows a dash, and a click on + // it turns ligatures on in every frame. + let liga = rows().into_iter().find(|(l, _)| *l == "Standard Ligatures").map(|(_, f)| f); + let Some(liga) = liga else { panic!("no Standard Ligatures row") }; + assert_eq!(shown_flag(&app, liga), None); + let ctx = egui::Context::default(); + let draw = |app: &mut VectorcraftApp, ui: &mut Ui| show(app, ui); + super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], draw); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], draw); + let at = super::super::tests_appearance::text_rect(&texts, "Standard Ligatures").center(); + let undo = undo_steps(&app); + super::super::tests_appearance::click(&ctx, &mut app, at, draw); + assert_eq!(undo_steps(&app), undo + 1, "one undo step"); + assert_eq!(shown_flag(&app, liga), Some(true)); + for id in [a, b, c] { + assert!(frame_features(&app, id).iter().all(|f| !f.iter().any(|t| t == "-liga")), "frame {id} has ligatures"); + } + assert!(has(&app, c, "smcp"), "the hidden frame keeps its other features"); + + // Two frames of the story and the other type in a group: each story once. + app.run("object.unlockAll", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a, other]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group, b]})).unwrap(); + edit(&mut app, Change::Position(1)); + for id in [a, b, c, other] { + assert!(frame_features(&app, id).iter().all(|f| f.iter().filter(|t| *t == "sups").count() == 1), "frame {id} has sups once"); + } + } + /// Type with two positions turned on shows the Position menu as mixed, and the entry of one /// of them turns the other off. #[test] diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index e02d81e14c..e594052f60 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -79,13 +79,7 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { /// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result. With /// the Selection tool these are the paragraphs of every frame of the selected stories /// ([`super::character::selected_stories`]). -pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Result { - run_on(app, cmd, p, true) -} - -/// [`run_on_paragraphs`], with the Selection tool on the selected type objects alone unless -/// `stories`. -fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> Result { +pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result { if let Some((id, a, b)) = super::character::text_editing(app) { super::character::end_typing(app); match super::character::text_story(app) { @@ -97,7 +91,7 @@ fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> R p["end"] = json!(b); } } - } else if stories { + } else { super::character::target_selected_stories(app, &mut p); } app.run(cmd, p) @@ -260,7 +254,7 @@ pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { +pub(crate) fn shared_para(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -474,12 +468,13 @@ pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) - } /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the -/// list paragraphs of the selected type, or of those the Type tool's selection touches, become -/// characters of the text and the lists are turned off (`text.convertListToText`, which also moves -/// the Type tool's anchor and caret past the characters inserted at or before them). +/// list paragraphs the Paragraph panel acts on ([`run_on_paragraphs`]: with the Selection tool, +/// every frame of the selected stories) become characters of the text and the lists are turned +/// off (`text.convertListToText`, which also moves the Type tool's anchor and caret past the +/// characters inserted at or before them). pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result { text_style(app).ok_or("select some type first")?; - run_on(app, "text.convertListToText", json!({}), false) + run_on_paragraphs(app, "text.convertListToText", json!({})) } /// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 180f0b1fa1..f6c9f064dc 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -678,6 +678,50 @@ fn convert_to_text_keeps_the_caret_by_its_character_in_threaded_type() { assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(a))); } +/// With the Selection tool, Convert to Text takes the same story as Apply Numbers: the numbers +/// of every frame of the selected story, its locked and hidden frames included, become characters +/// of the text in one undo step, and other type keeps its list. +#[test] +fn selection_tool_convert_to_text_takes_every_frame_of_the_story() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [other], "list": "bullet"})).unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let frame = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.plain_text(), (0..t.paragraph_count()).map(|i| t.para_at(i).list.is_some()).collect::>()), + _ => panic!("not text"), + }; + let undo_steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + app.run("type.applyNumbers", json!({})).unwrap(); + for id in [a, b, c] { + let (_, lists) = frame(&app, id); + assert!(!lists.is_empty() && lists.iter().all(|l| *l), "Apply Numbers numbers frame {id}"); + } + let before = frame(&app, c); + + let steps = undo_steps(&app); + app.run("type.convertListToText", json!({})).unwrap(); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + for id in [a, b, c] { + let (plain, lists) = frame(&app, id); + assert!(lists.iter().all(|l| !*l), "frame {id} leaves the list"); + assert!(plain.contains(".\tThe quick"), "frame {id} holds its numbers as text: {plain:?}"); + } + assert_eq!(frame(&app, other), ("Other".to_string(), vec![true]), "other type keeps its list"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(frame(&app, c), before, "undo restores the hidden frame"); +} + /// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation and /// Justification options back to their defaults in every paragraph it acts on, so the paragraphs /// save neither. 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/panels/tests_symbol_libraries.rs b/crates/ui-egui/src/panels/tests_symbol_libraries.rs index 9c58bc49c7..955b14327a 100644 --- a/crates/ui-egui/src/panels/tests_symbol_libraries.rs +++ b/crates/ui-egui/src/panels/tests_symbol_libraries.rs @@ -144,7 +144,10 @@ fn the_window_menu_and_the_symbols_panel_open_symbol_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Symbol Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.symbolLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons"), json!("skill-icons")]); + assert_eq!( + opened, + [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons"), json!("skill-icons"), json!("weather-icons")] + ); let has = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(has("window.symbolLibrary.other"), Some(true)); assert_eq!(has("ui.saveSymbolLibrary"), Some(false), "no symbols to save yet"); diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 880de94c16..4f8ba904de 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -1,10 +1,11 @@ //! Character Styles and Paragraph Styles panels (one implementation, two kinds). //! -//! Click a style to apply it to the selected text (or the Type tool's selected characters); -//! Alt-click applies it and clears overrides. Double-clicking a character style's row opens its -//! Character Style Options (`ui.charStyleOptions`), as the panel menu does; a paragraph style's row -//! opens its Paragraph Style Options (`ui.paraStyleOptions`). The style of the -//! selection is highlighted, with a "+" when the text overrides some of its attributes. New makes +//! Click a style to apply it to the selected text (every frame of each selected threaded story) or +//! the Type tool's selected characters; Alt-click applies it and clears overrides. Double-clicking a +//! character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel +//! menu does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`). +//! The style all of that text has is highlighted, with a "+" when some of the text overrides some +//! of its attributes; text in several styles highlights none. New makes //! a style from the selected text; Alt-click on New first asks for its name (`ui.newCharStyle`, //! `ui.newParaStyle`). @@ -15,8 +16,9 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; use vectorcraft_engine::cmd::{fileio, textstyles}; -use super::character::{end_typing, text_editing, text_style}; +use super::character::{end_typing, shared, text_editing, text_style}; use super::memo::Memo; +use super::paragraph::shared_para; use super::{alt_held, pstate, selection_len, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; @@ -172,14 +174,25 @@ thread_local! { static LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// The selection's style name (Normal when none) and every one of its attributes, written as a -/// style holds them, if text is selected. -fn current(app: &VectorcraftApp, kind: Kind) -> Option<(Option, Map)> { - let (c, p) = text_style(app)?; - Some(match kind { - Kind::Char => (c.style_name.clone(), textstyles::char_attrs(&c)), - Kind::Para => (p.style_name.clone(), textstyles::para_attrs(&p)), - }) +/// The style name (`None`: the Normal style) that every character the panel acts on has (paragraph +/// styles: every paragraph), with the Selection tool in every frame of the selected stories; +/// `None` when they differ or no type is selected. +fn current(app: &VectorcraftApp, kind: Kind) -> Option> { + match kind { + Kind::Char => shared(app, |s| s.style_name.clone()), + Kind::Para => shared_para(app, |p| p.style_name.clone()), + } +} + +/// Whether any character (paragraph styles: paragraph) the panel acts on differs from the style +/// attributes `attrs` in one of them. +fn has_overrides(app: &VectorcraftApp, kind: Kind, attrs: &Map) -> bool { + let differs = |m: Map| attrs.iter().any(|(k, v)| m.get(k) != Some(v)); + let same = match kind { + Kind::Char => shared(app, |s| differs(textstyles::char_attrs(s))), + Kind::Para => shared_para(app, |p| differs(textstyles::para_attrs(p))), + }; + same != Some(false) } /// Parameters targeting the selection: the Type tool's selected characters (paragraph styles: the @@ -196,14 +209,19 @@ fn target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value { } /// [`target`] for applying a style: after Select All in threaded text, all the text of every -/// frame of the story. +/// frame of the story; with the Selection tool, every frame of the selected stories +/// ([`super::character::selected_stories`]). fn apply_target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value { + if text_editing(app).is_none() { + super::character::target_selected_stories(app, &mut p); + return p; + } match super::character::text_story(app) { - Some(story) if text_editing(app).is_some() => { + Some(story) => { p["ids"] = json!(story.iter().map(|f| f.0).collect::>()); p } - _ => target(app, kind, p), + None => target(app, kind, p), } } @@ -242,9 +260,8 @@ fn open_style_options(app: &mut VectorcraftApp, kind: Kind, name: &str) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let t = Tokens::get(ui.ctx()); let all = styles(app, kind); - let cur = current(app, kind); let normal = all.first().map(|s| s.0.clone()).unwrap_or_default(); - let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone())); + let cur_name = current(app, kind).map(|n| n.unwrap_or_else(|| normal.clone())); let mut clicked: Option<(String, egui::Modifiers)> = None; // A double-clicked style's row, whose options open after the list is laid out. let mut options: Option = None; @@ -270,8 +287,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { widgets::list_box(ui, |ui| { for (name, attrs, _) in &all { let is_cur = cur_name.as_deref() == Some(name.as_str()); - // Overrides: an attribute the style sets that the selection doesn't match. - let overridden = is_cur && cur.as_ref().is_some_and(|(_, m)| attrs.iter().any(|(k, v)| m.get(k) != Some(v))); + // Overrides: an attribute the style sets that some of the text doesn't match. + let overridden = is_cur && has_overrides(app, kind, attrs); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); if resp.drag_started_by(egui::PointerButton::Primary) { egui::DragAndDrop::set_payload(ui.ctx(), RowDrag { kind, name: name.clone() }); @@ -310,7 +327,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if scroll_to.is_some() { set_pstate::>(ui.ctx(), kind.scroll_key(), None); } - let has_text = cur.is_some(); + let has_text = text_style(app).is_some(); if let Some((name, m)) = clicked { sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m); // Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. The second @@ -381,7 +398,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let normal = if kind == Kind::Char { "[Normal Character Style]" } else { "[Normal Paragraph Style]" }; let user = sel.as_ref().is_some_and(|s| s != normal); let doomed: Vec<&String> = many.iter().filter(|s| *s != normal).collect(); - let has_text = current(app, kind).is_some() || selection_len(app) > 0; + let has_text = text_style(app).is_some() || selection_len(app) > 0; let p = kind.prefix(); if menu_item(ui, &crate::i18n::fmt(tl!("New {kind} Style…"), &[("kind", kind.what_label())]), true, false) { // A later typing preview must not restore the document from before this capture. diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index a83103aec8..e550f82ab4 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -29,8 +29,8 @@ fn panels_draw_and_apply_headless() { out.textures_delta.clear(); } app.run("charStyle.apply", target(&app, Kind::Char, json!({"name": "Big"}))).unwrap(); - let (name, attrs) = current(&app, Kind::Char).unwrap(); - assert_eq!((name.as_deref(), attrs["size"].as_f64()), (Some("Big"), Some(30.0))); + let (style, _) = text_style(&app).unwrap(); + assert_eq!((current(&app, Kind::Char), style.size), (Some(Some("Big".to_string())), 30.0)); } #[test] @@ -818,6 +818,74 @@ fn the_styles_are_listed_once_per_document_version() { } } +/// The style names of `id`'s runs (`Kind::Para`: of its paragraphs). +fn frame_style_names(app: &VectorcraftApp, kind: Kind, id: u64) -> Vec> { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) if kind == Kind::Char => t.runs.iter().map(|r| r.style.style_name.clone()).collect(), + NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).style_name.clone()).collect(), + _ => panic!("not text"), + } +} + +/// With the Selection tool a selected frame of threaded text stands for its whole story, in both +/// panels: no row shows as the current style while a locked or hidden frame has another style, a +/// row click applies the style to every frame in one undo step and leaves other type alone, the +/// row shows "+" while a hidden frame overrides the style, and an Alt-click clears that override. +#[test] +fn selection_tool_styles_reach_every_frame_of_the_story() { + use egui::Modifiers; + for kind in [Kind::Char, Kind::Para] { + let what = kind.what(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Plain"})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Second", "attrs": {}})).unwrap(); + app.run(&format!("{}.apply", kind.prefix()), json!({"name": "Second", "ids": [c]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + for id in [a, b, c] { + assert!(!frame_style_names(&app, kind, id).is_empty(), "frame {id} holds text"); + } + assert_eq!(current(&app, kind), None, "{what} Styles: the hidden frame has another style"); + + let ctx = egui::Context::default(); + let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + ui.set_width(280.0); + show(app, ui, kind); + }; + let undo = app.session.doc().unwrap().history.undo.len(); + click_text_held(&ctx, &mut app, "Plain", Modifiers::NONE, rows); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "{what} Styles: one undo step"); + for id in [a, b, c] { + assert!(frame_style_names(&app, kind, id).iter().all(|n| n.as_deref() == Some("Plain")), "{what} Styles: frame {id}"); + } + assert!(frame_style_names(&app, kind, other).iter().all(Option::is_none), "{what} Styles: other type keeps its style"); + assert_eq!(current(&app, kind), Some(Some("Plain".to_string()))); + + app.run("text.setFormat", json!({"ids": [c], "kerning": 40, "composer": "singleLine"})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain+"), "{what} Styles: the hidden frame's override shows: {shown}"); + // A new context, so that this click does not count as the second click of a double-click. + click_text_held(&egui::Context::default(), &mut app, "Plain+", Modifiers::ALT, rows); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain"), "{what} Styles: Alt-click clears the override: {shown}"); + app.run("edit.undo", json!({})).unwrap(); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain+"), "{what} Styles: undo brings the override back: {shown}"); + } +} + /// A row dragged from the highlighted styles carries all of them: dropped on Create New Style it /// duplicates each in one undo step and highlights the copies, and dropped on Delete it deletes each /// but the Normal style in one undo step and clears the highlight. A row from outside the highlight @@ -1201,7 +1269,7 @@ fn alt_clicking_new_paragraph_style_asks_for_its_name() { assert!(app.session.doc().unwrap().doc.para_styles.is_empty(), "no style is added before OK"); set_name(&mut app, "Body Copy"); crate::dialogs::confirm(&mut app).unwrap(); - let (_, attrs) = current(&app, Kind::Para).unwrap(); + let attrs = textstyles::para_attrs(&text_style(&app).unwrap().1); let doc = &app.session.doc().unwrap().doc; assert_eq!(doc.para_styles.iter().map(|s| (s.name.as_str(), &s.attrs)).collect::>(), [("Body Copy", &attrs)]); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 229a23c411..4279ac11e4 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -20,12 +20,12 @@ pub(super) const MASK_THUMB: &str = "tr-mask-thumb"; /// The panel's values ([`vectorcraft_engine::Session::transparency_info`]) and its first target /// (the selection, or the object whose mask is being edited), recomputed only when the document -/// or the selection changes. +/// or the selection changes, or other gradient mesh points are picked. fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (TransparencyInfo, Option) { - type Key = (u64, u64, usize, Option, Option); + type Key = (u64, u64, usize, Option, Option, Option)>>); let Some(st) = app.session.active() else { return Default::default() }; let sel = &st.selection.objects; - let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied()); + let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied(), app.session.mesh_points_picked()); let id = egui::Id::unique("tr-state"); if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (TransparencyInfo, Option))>(id)) && k == key @@ -351,6 +351,50 @@ mod tests { assert!(app.session.active().unwrap().doc.mask_edit.is_none()); } + /// Picking other gradient mesh points (which gives the document no new revision) shows their + /// opacity, the one `transparency.set` changes then. + #[test] + fn picking_mesh_points_shows_their_opacity() { + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].clone(); + run(&mut app, "object.mesh.create", json!({"rows": 2, "cols": 2})); + run(&mut app, "object.mesh.setPointColor", json!({"id": a, "index": 0, "opacity": 0.3})); + run(&mut app, "paint.setStroke", json!({"color": "#000000"})); + let v = ViewInfo::default(); + app.session.select_tool("mesh", v).unwrap(); + let click = |app: &mut VectorcraftApp, x: f64, y: f64| { + for k in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + }; + let picked = |app: &VectorcraftApp| app.session.mesh_points_picked().map(|p| p.into_iter().flat_map(|(_, v)| v).collect::>()); + let ctx = egui::Context::default(); + click(&mut app, 35.0, 10.0); + assert_ne!(picked(&app), Some(vec![0]), "the click picks the top middle point"); + assert_eq!(picked(&app).map(|v| v.len()), Some(1)); + assert_eq!(state(&app, &ctx).0.opacity, Some(100.0)); + click(&mut app, 10.0, 10.0); + assert_eq!(picked(&app), Some(vec![0]), "the click picks the top-left point"); + assert_eq!(state(&app, &ctx).0.opacity, Some(30.0), "the newly picked point's opacity"); + // An active Appearance row takes the opacity, so the values are the object's again. + let rows = app + .session + .active() + .unwrap() + .selection + .objects + .first() + .map(|id| app.session.active().unwrap().doc.node(*id).unwrap().appearance.items.len()); + assert!(rows.is_some_and(|n| n > 0), "the mesh has its stroke row"); + run(&mut app, "appearance.setActiveItem", json!({"index": 0})); + assert_eq!(picked(&app), None); + assert_eq!(app.session.transparency_info().opacity, Some(100.0), "the object's opacity, not the point's"); + assert_eq!(run(&mut app, "transparency.info", json!({}))["opacity"], json!(100.0)); + } + #[test] fn mixed_values_show_blank() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); 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/recovery.rs b/crates/ui-egui/src/recovery.rs index aa0a25e50f..9d787954ea 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -125,24 +125,37 @@ fn write(app: &mut VectorcraftApp, store: &Arc, pick: impl Fn for job in jobs { let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone()); let (done, at, saving) = (job.clone(), place.clone(), label.clone()); + // The status line before the job starts: on the worker, the job sets one line ("