diff --git a/Cargo.lock b/Cargo.lock index c8d3381d6c..f3e85a08a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,12 +508,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bit_field" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1" - [[package]] name = "bitflags" version = "1.3.2" @@ -1039,36 +1033,6 @@ dependencies = [ "writeable", ] -[[package]] -name = "craft-image-codecs" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "craft-jpeg", - "exr", - "flate2", - "half", - "image", - "image-webp", - "jpeg-encoder", - "png 0.18.1", - "rayon", - "serde_json", - "thiserror 2.0.21", - "tiff", - "zune-core 0.5.3", - "zune-jpeg 0.5.15", - "zune-jpegxl", -] - -[[package]] -name = "craft-jpeg" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "thiserror 2.0.21", -] - [[package]] name = "craft-keymap" version = "0.1.0" @@ -1115,23 +1079,12 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "craft-psd" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "encoding_rs", - "flate2", - "rayon", - "thiserror 2.0.21", -] - [[package]] name = "craft-svg" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" dependencies = [ - "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2)", + "craft-xml", "flate2", "kurbo 0.13.1", "roxmltree 0.21.2", @@ -1141,15 +1094,6 @@ dependencies = [ "vectorcraft-pathops 0.18.0", ] -[[package]] -name = "craft-tiff" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "serde", - "thiserror 2.0.21", -] - [[package]] name = "craft-ui" version = "0.1.0" @@ -1248,15 +1192,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "craft-xml" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "quick-xml 0.41.0", - "thiserror 2.0.21", -] - [[package]] name = "crc32fast" version = "1.5.3" @@ -1769,22 +1704,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide 0.8.9", - "multiversion", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "fastrand" version = "2.5.0" @@ -2817,7 +2736,6 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", - "qoi", "tiff", "zune-core 0.5.3", "zune-jpeg 0.5.15", @@ -3271,12 +3189,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" version = "0.2.191" @@ -3476,28 +3388,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "multiversion" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076" -dependencies = [ - "multiversion-macros", - "target-features", -] - -[[package]] -name = "multiversion-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "target-features", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -4448,21 +4338,21 @@ dependencies = [ "unarray", ] +[[package]] +name = "psd" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "quick-error" version = "1.2.3" @@ -5833,12 +5723,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "target-features" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" - [[package]] name = "tempfile" version = "3.27.0" @@ -6651,7 +6535,6 @@ dependencies = [ "chrono", "craft-docpath", "craft-proof", - "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "crc32fast", "flate2", "hayro-jpeg2000", @@ -6664,6 +6547,7 @@ dependencies = [ "log", "proptest", "rayon", + "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", "same-file", @@ -6837,7 +6721,6 @@ dependencies = [ "aes", "chrono", "craft-pdf", - "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "flate2", "getrandom 0.4.3", "image", @@ -6848,6 +6731,7 @@ dependencies = [ "md-5", "objc2-core-foundation", "objc2-core-graphics", + "roxmltree 0.21.2", "serde", "serde_json", "sha2", @@ -6888,9 +6772,6 @@ dependencies = [ name = "vectorcraft-render" version = "0.24.1" dependencies = [ - "craft-image-codecs", - "craft-psd", - "craft-tiff", "flate2", "gif 0.14.2", "image", @@ -6898,7 +6779,9 @@ dependencies = [ "log", "png 0.18.1", "proptest", + "psd", "serde_json", + "tiff", "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", @@ -8473,15 +8356,6 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - [[package]] name = "zune-jpeg" version = "0.4.21" diff --git a/Cargo.toml b/Cargo.toml index 109f4d6f71..c7856d589e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,6 @@ craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } # DXF tags, reader and writer (craft-libs#778, #926, #927). craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } -craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } -craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } -craft-gif = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } -craft-tiff = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } -craft-psd = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index bcd37ecce3..ec6256a114 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -17,7 +17,8 @@ //! 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, -//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). +//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). A +//! file of more than 64 MiB is skipped before it is read. use std::io::{Cursor, Read}; use std::path::PathBuf; @@ -144,9 +145,18 @@ fn dib_image(dib: &[u8]) -> Option { Some(img) } +/// The most bytes a paste takes from another app (untrusted data), VectorCraft's own limit: a copied +/// file of more is skipped, and on Wayland a read of more fails. +const MAX_BYTES: usize = 64 << 20; + /// The first of `paths` (files copied in a file manager) that pastes as one of `mimes`, read -/// whole. Only its first bytes are read to tell, so other files cost little. +/// whole, at most [`MAX_BYTES`]. Only its first bytes are read to tell, so other files cost little. fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option { + copied_file_within(paths, mimes, MAX_BYTES) +} + +/// [`copied_file`] with a file of more than `limit` bytes skipped. +fn copied_file_within(paths: Vec, mimes: &[&'static str], limit: usize) -> Option { paths.into_iter().find_map(|path| { // text/uri-list lines end in CRLF (RFC 2483), but arboard splits them on LF only. let path = match path.to_str() { @@ -164,14 +174,29 @@ fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option { if !std::fs::metadata(&path).ok()?.is_file() { return None; } + let mut file = std::fs::File::open(&path).ok()?; + // The file opened may not be the one looked at: its size is checked before any read. + let meta = file.metadata().ok()?; + if !meta.is_file() || meta.len() > limit as u64 { + return None; + } let mut head = Vec::with_capacity(FILE_HEAD); - std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?; + (&mut file).take(FILE_HEAD as u64).read_to_end(&mut head).ok()?; let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?; - let data = std::fs::read(&path).ok()?; + let data = read_rest(file, head, limit)?; (!data.is_empty()).then_some(Flavour { mime, data }) }) } +/// `data` (the first bytes read of `file`) followed by the rest of `file`, or `None` when they come +/// to more than `limit` bytes, as when the file grew after its size was checked. At most one byte +/// past `limit` is read. +fn read_rest(file: impl Read, mut data: Vec, limit: usize) -> Option> { + let more = (limit + 1).saturating_sub(data.len()); + file.take(more as u64).read_to_end(&mut data).ok()?; + (data.len() <= limit).then_some(data) +} + /// 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; @@ -1900,7 +1925,7 @@ 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, PathBuf, SVG, TEXT, copied_file, gnome_copied_paths, uri_list_paths}; + use super::{BITMAP, Flavour, MAX_BYTES, 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`. @@ -1911,9 +1936,6 @@ mod wayland { /// 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; /// The most pastes of a Copy's data that are sent at once; a paste beyond them gets nothing. const MAX_SENDS: usize = 8; /// The most types of an offer that are kept (untrusted data). @@ -3176,6 +3198,51 @@ mod tests { assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG)); } + /// A copied file of more than 64 MiB is skipped before it is read, and the next one pastes. + #[test] + fn an_oversized_copied_file_is_skipped_before_it_is_read() { + let d = dir("oversized"); + let (big, small) = (d.path().join("big.png"), d.path().join("small.png")); + std::fs::write(&big, png(2, 2)).unwrap(); + // Sparse where the file system allows: no 64 MiB is written. + std::fs::OpenOptions::new().write(true).open(&big).unwrap().set_len(MAX_BYTES as u64 + 1).unwrap(); + std::fs::write(&small, png(3, 2)).unwrap(); + assert!(copied_file(vec![big.clone()], &PASTE_ORDER).is_none()); + let f = copied_file(vec![big, small], &PASTE_ORDER).unwrap(); + assert_eq!((f.mime, f.data), (PNG, png(3, 2))); + } + + /// A copied file of exactly the limit pastes; one byte more and it is skipped. + #[test] + fn a_copied_file_pastes_up_to_the_limit() { + let d = dir("limit"); + let art = d.path().join("art.svg"); + let svg = ""; + std::fs::write(&art, svg).unwrap(); + let read = |limit| copied_file_within(vec![art.clone()], &PASTE_ORDER, limit).map(|f| f.data); + assert_eq!(read(svg.len()), Some(svg.as_bytes().to_vec())); + assert_eq!(read(svg.len() - 1), None); + } + + /// A file that grows past the limit after its size was checked is read to one byte past the + /// limit and refused. + #[test] + fn the_rest_of_a_file_is_read_to_the_limit_at_most() { + let read = |len: usize, head: usize| { + let bytes = vec![7u8; len]; + let mut file = Cursor::new(bytes); + let mut first = vec![0; head]; + file.read_exact(&mut first).unwrap(); + (read_rest(&mut file, first, 16).map(|d| d.len()), file.position()) + }; + assert_eq!(read(16, 4), (Some(16), 16)); + assert_eq!(read(0, 0), (Some(0), 0)); + assert_eq!(read(17, 4), (None, 17)); + assert_eq!(read(1000, 4), (None, 17)); + // First bytes already past the limit: nothing more is read. + assert_eq!(read(1000, 20), (None, 20)); + } + fn uri_paths(list: &[u8]) -> Vec { uri_list_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect() } diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 6ab80c8ba0..95c8ecfb7a 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -1024,6 +1024,26 @@ fn overlaps(a: Rect, b: Rect) -> bool { a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 } +/// Whether a plot draws nothing of `n` wherever it is, apart from what every export leaves out +/// (hidden objects, guides and template layers): an object at 0% opacity, a non-printing layer, a +/// placed image, a gradient mesh whose every point is at 0% opacity, or a path or compound path +/// without effects whose fills and strokes paint nothing. An image or a mesh in `doc` with a +/// visible Crop Marks effect, or with a Vector Halftone effect that makes dots, is not one of them. +/// A clipping path still clips when the plot draws nothing of it. +pub fn plots_nothing(doc: &Document, n: &Node) -> bool { + let unpainted = || n.appearance.effects.is_empty() && top_stroke(n).is_none() && top_fill(n).is_none(); + let dots = || vectorcraft_effects::addons::halftone::halftone_art(doc, n).is_some_and(|g| g.children().is_some_and(|c| !c.is_empty())); + let adds_art = || vectorcraft_effects::has_crop_marks(n) || dots(); + n.opacity <= 0.0 + || match &n.kind { + NodeKind::Layer { printable, .. } => !printable, + NodeKind::Image(_) => !adds_art(), + NodeKind::Mesh(m) => !adds_art() && m.points.iter().all(|p| p.opacity <= 0.0), + NodeKind::Path { .. } | NodeKind::Compound { .. } => unpainted(), + _ => false, + } +} + /// `nodes` (the children of a clipping container when `clipping`) without the layers and /// sublayers a plot leaves out (hidden, non-printing, template or at 0% opacity) and without /// guides, in layers and groups at any depth, so that baking can't wrap one in art that is drawn diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 9cb4f7d727..60f7cf5b3d 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -177,6 +177,90 @@ fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() { assert_eq!(t.matches("PD").count(), 3, "{t}"); } +/// `plots_nothing` picks the objects and layers that a plot draws nothing of: a path that paints +/// nothing, objects at 0% opacity, placed images, meshes with every point at 0% opacity and +/// non-printing layers, and not a painted path or a mesh. +#[test] +fn plots_nothing_picks_what_a_plot_draws_nothing_of() { + type Make = fn(&mut Document) -> Node; + let cases: [(&str, Make, bool); 7] = [ + ("stroked", |d| stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK), false), + ("unpainted", |d| Node::path(d.alloc_id(), rect(10.0, 10.0, 40.0, 40.0), Appearance::basic(Paint::None, Paint::None, 1.0)), true), + ( + "clear", + |d| { + let mut n = filled(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK); + n.opacity = 0.0; + n + }, + true, + ), + ( + "image", + |d| { + let image = vectorcraft_doc::ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))) + }, + true, + ), + ( + "image with Crop Marks", + |d| { + let image = vectorcraft_doc::ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true }); + n + }, + false, + ), + ("mesh", |d| Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), false), + ( + "clear mesh", + |d| { + let mut clear = mesh_at(10.0); + clear.points.iter_mut().for_each(|p| p.opacity = 0.0); + Node::new(d.alloc_id(), NodeKind::Mesh(clear)) + }, + true, + ), + ]; + for (name, make, nothing) in cases { + let mut held = None; + let d = doc_with(|d| { + let n = make(d); + held = Some(n.clone()); + vec![n] + }); + let Some(n) = held else { panic!("{name}") }; + assert_eq!(plots_nothing(&d, &n), nothing, "{name}"); + assert_eq!(text(&plot(&d, &opts(&d))) == "IN;PU;SP0;", nothing, "{name}"); + } + let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK)]); + let layer = d.layers[0].id; + assert!(!plots_nothing(&d, &d.layers[0])); + if let Some(NodeKind::Layer { printable, .. }) = d.node_mut(layer).map(|n| &mut n.kind) { + *printable = false; + } + assert!(plots_nothing(&d, &d.layers[0])); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); +} + /// Live art that makes more objects than the plot's limit (1,000 in tests) is left out with a /// warning. #[test] diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 0ee88bf3b7..3a5626054b 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -8,6 +8,10 @@ description = "VectorCraft document model: artboards, layers, objects, appearanc [lints] workspace = true +[features] +# `vectorcraft_doc::blend::spines_built` for other crates' tests (dev-dependencies only). +spine-count = [] + [dependencies] vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 2c269766d5..31ca462799 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1255,13 +1255,21 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> // Spine // ===================================================================================== -#[cfg(test)] +#[cfg(any(test, feature = "spine-count"))] thread_local! { /// How many times [`Spine::new`] has run on this thread, for tests that count how often a path /// is flattened. pub(crate) static SPINES_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// How many times [`Spine::new`] has run on this thread, for other crates' tests that count how +/// often a path is flattened (the `spine-count` feature, dev-dependencies only). +#[cfg(any(test, feature = "spine-count"))] +#[doc(hidden)] +pub fn spines_built() -> usize { + SPINES_BUILT.with(|n| n.get()) +} + /// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the /// arc length at each of its anchors. pub struct Spine { @@ -1280,7 +1288,7 @@ impl Spine { /// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when /// the first one flattens to fewer than two points (so it has no length to walk). pub fn new(path: &PathData) -> Option { - #[cfg(test)] + #[cfg(any(test, feature = "spine-count"))] SPINES_BUILT.with(|n| n.set(n.get() + 1)); let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index d561663524..2746390b86 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -150,6 +150,10 @@ pub struct LabelStyles { pub categories: Option, #[serde(skip_serializing_if = "Option::is_none")] pub legend: Option, + /// The fill of [`Self::legend`] was set on the legend labels, so it is drawn on every pie wedge with Legends in + /// Wedges. `false`: a black fill there is the default, and the labels on dark wedges are drawn white. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub legend_fill_set: bool, #[serde(skip_serializing_if = "Option::is_none")] pub values_para: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -182,6 +186,18 @@ impl LabelStyles { fn is_default(&self) -> bool { *self == Self::default() } + + /// Is the kept legend fill the legend labels' own: set on them ([`Self::legend_fill_set`]), or not black (a file + /// saved without the mark)? Otherwise it is the default black, which edits that map the kept paints leave as it is. + pub fn legend_fill_is_own(&self) -> bool { + self.legend.as_ref().is_some_and(|st| self.legend_fill_set || st.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK)) + } + + /// Mark a kept legend fill that is the labels' own ([`Self::legend_fill_is_own`]) as set on them, before an edit + /// that maps the kept paints can turn it black. + pub fn mark_own_legend_fill(&mut self) { + self.legend_fill_set = self.legend_fill_is_own(); + } } /// Graph Type › Position for pie graphs with several pies: the same size (Even), sized by their totals (Ratio), or @@ -532,6 +548,14 @@ impl GraphSpec { self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind) } + /// Does the graph 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 ([`LabelStyles::legend_fill_is_own`])? Only + /// then is a black legend fill it keeps the default, which edits that map the kept paints leave as it is; other + /// graphs draw the kept fill as it is. + pub fn picks_legend_fill(&self) -> bool { + self.kind == GraphKind::Pie && self.legend && self.pie_legend_in_wedges && !self.label_styles.legend_fill_is_own() + } + /// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows /// of [`MAX_GRAPH_SERIES`] cells). pub fn cells(&self) -> Vec>> { diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index fd907a26cc..93b690ccf8 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -974,43 +974,45 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option /// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and /// how the canvas places each object of it: through the symbol's 9-slice guides when the instance /// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. -struct InstanceArt { +struct InstanceArt<'a> { art: Node, map: Option, xf: vectorcraft_geom::Affine, + /// Quick boxes, which measure the instances in the art as it is placed through the guides. + boxes: SymbolBoxes<'a>, } -impl InstanceArt { +impl<'a> InstanceArt<'a> { /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. - fn of(doc: &Document, inst: &Node) -> Option { + fn of(doc: &'a Document, inst: &Node) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; - let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; - let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); - let map = doc.symbol_slice_map(symbol, *xf); - if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + let boxes = doc.symbol_boxes().quick(); + let art = crate::symbol_overrides::dynamic_art(boxes.symbol_art(symbol)?, inst); + let map = boxes.slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed_with(&art, &boxes) > crate::MAX_SYMBOL_NODES) { return None; } - Some(Self { art, map, xf: *xf }) + Some(Self { art, map, xf: *xf, boxes }) } /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides /// object by object. - fn whole(&self, n: &Node, doc: &Document, whole: Option) -> Option { - whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + fn whole(&self, n: &Node, whole: Option) -> Option { + whole.or_else(|| self.map.as_ref()?.one_cell_with(n, &self.boxes)) } /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as /// [`Document::place_symbol_art`] refuses it. - fn place(&self, n: &Node, doc: &Document, whole: Option) -> Option { + fn place(&self, n: &Node, whole: Option) -> Option { match (&self.map, whole) { (_, Some(a)) => Some(moved(n, a)), - (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (Some(m), None) => (m.most_placed_with(n, &self.boxes) <= crate::MAX_SYMBOL_NODES).then(|| m.place_with(n, &self.boxes)), (None, None) => Some(moved(n, self.xf)), } } @@ -1018,10 +1020,13 @@ impl InstanceArt { /// Object `n` of the art placed on the canvas without clip groups /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the /// same limit ([`Document::place_symbol_art_unclipped`]). - fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option) -> Option { + fn place_unclipped(&self, n: &Node, whole: Option) -> Option { match (&self.map, whole) { (_, Some(a)) => Some(moved(n, a)), - (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (Some(m), None) => { + let m = m.unclipped(); + (m.most_placed_with(n, &self.boxes) <= crate::MAX_SYMBOL_NODES).then(|| m.place_with(n, &self.boxes)) + } (None, None) => Some(moved(n, self.xf)), } } @@ -1046,15 +1051,14 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { struct Find<'a> { - doc: &'a Document, - art: &'a InstanceArt, + art: &'a InstanceArt<'a>, p: Point, opt: HitOptions, symbols: &'a SymbolBoxes<'a>, } impl Find<'_> { fn hit(&self, n: &Node, whole: Option) -> bool { - self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) } fn skips_guide(&self, n: &Node) -> bool { self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) @@ -1067,11 +1071,11 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { return self.hit(n, whole); } - let whole = self.art.whole(n, self.doc, whole); + let whole = self.art.whole(n, whole); if n.clips() && let Some(first) = children.first() { - let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + let Some(clip) = self.art.place_unclipped(first, whole) else { return false }; if self.opt.clip_outlines && first.visible && !first.locked @@ -1103,7 +1107,7 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; - Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) + Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) } /// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), @@ -1123,11 +1127,11 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< let mut n = &art.art; for &i in path { let children = n.children()?; - whole = art.whole(n, doc, whole); + whole = art.whole(n, whole); if i != 0 && n.clips() && let Some(first) = children.first() - && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + && let Some(clip) = art.place_unclipped(first, whole)?.geometric_bounds() { match meet(Some(clip), limit) { Some(r) => limit = Some(r), @@ -1136,7 +1140,7 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< } n = children.get(usize::try_from(i).ok()?)?; } - outline_within(&art.place(part, doc, whole)?, limit, &mut out); + outline_within(&art.place(part, whole)?, limit, &mut out); Some(out) } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 3eac4202b6..a4d1cbbf10 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1586,6 +1586,53 @@ fn symbol_art_past_the_object_limit_picks_nothing() { assert!(symbol_part_outline(&d, n, &[0]).is_some()); } +/// Picking and outlining an object of a 9-slice instance's art, and placing that art, read the +/// symbol list once for each call, however many instances the art holds. +#[test] +fn picking_in_9_slice_art_reads_the_symbol_list_once() { + let mut d = Document::new(400.0, 400.0); + for i in 0..2_000 { + let dot = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()); + d.symbols.push(crate::Symbol { dynamic: false, name: format!("Dot {i}"), art: Arc::new(dot) }); + } + // A frame, a group of 20 squares in its top left cell, and 20 instances of the dots across + // the guides. + let square = |d: &mut Document, x: f64, y: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 4.0, y + 4.0)), Appearance::default_art())) + }; + let corner: Vec<_> = (0..20).map(|i| square(&mut d, f64::from(i % 5) * 4.0, f64::from(i / 5) * 4.0)).collect(); + let dot = |d: &mut Document, i: u32| { + let xf = vectorcraft_geom::Affine::translate((f64::from(i) * 4.5 + 5.0, 48.0)) * vectorcraft_geom::Affine::scale(4.0); + Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("Dot {i}"), xf })) + }; + let across: Vec<_> = (0..20).map(|i| dot(&mut d, i)).collect(); + let frame = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art())); + let mut kids = vec![frame, Arc::new(Node::group(d.alloc_id(), corner))]; + kids.extend(across); + let root = Node::group(d.alloc_id(), kids); + d.symbols.push(crate::Symbol { dynamic: true, name: "Panel".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf }); + let art = d.symbols[2_000].art.clone(); + let symbols = d.symbols.len(); + let read = || crate::symbols::SYMBOLS_READ.with(std::cell::Cell::get); + let reads = |f: &dyn Fn()| { + let before = read(); + f(); + read() - before + }; + // Picking also tests the placed art with boxes of its own, as a click outside symbols does. + let n = reads(&|| assert_eq!(symbol_part_at(&d, &inst, Point::new(2.0, 2.0), HitOptions::default()).map(|p| p[0]), Some(1))); + assert!(n <= 2 * symbols, "picking read {n} symbols"); + let n = reads(&|| assert!(symbol_part_outline(&d, &inst, &[2]).is_some())); + assert!(n <= symbols, "outlining read {n} symbols"); + let n = reads(&|| assert!(d.place_symbol_art("Panel", &art, xf).is_some())); + assert!(n <= symbols, "placing read {n} symbols"); + let n = reads(&|| assert!(d.place_symbol_art_unclipped("Panel", &art, xf).is_some())); + assert!(n <= symbols, "placing without clips read {n} symbols"); +} + /// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it /// included, so an object in it is picked and outlined there even when placing that object through /// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 35de7cce73..7362f324ea 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -234,8 +234,14 @@ pub fn is_objects_on_path(n: &Node) -> bool { /// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path /// (not a guide) whose first subpath has a length ([`path_length`]). pub fn attachable_path(n: &Node) -> Option<&PathData> { + attachable(n).map(|(path, _)| path) +} + +/// [`attachable_path`] with the path measured ([`Measured`]), from the one flattening that checks +/// its length. +pub fn attachable(n: &Node) -> Option<(&PathData, Measured)> { match &n.kind { - NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path), + NodeKind::Path { path, guide: false, .. } => Some((path, Measured::new(path)?)), _ => None, } } @@ -301,20 +307,51 @@ pub fn path_length(path: &PathData) -> Option<(f64, bool)> { /// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that /// one flattening serves both measuring the path and finding points along it. -struct Measured { +pub struct Measured { spine: Spine, len: f64, closed: bool, } +impl std::fmt::Debug for Measured { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Measured").field("len", &self.len).field("closed", &self.closed).finish_non_exhaustive() + } +} + impl Measured { /// `None` for a path without length, or with a length too large to be finite. - fn new(path: &PathData) -> Option { + pub fn new(path: &PathData) -> Option { let spine = Spine::new(path)?; let len = spine.length(); let closed = path.subpaths.first().is_some_and(|s| s.closed); (len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed }) } + /// The flattened first subpath, to measure along. + pub fn spine(&self) -> &Spine { + &self.spine + } + /// The length of the first subpath (points), as [`path_length`] gives it. + pub fn length(&self) -> f64 { + self.len + } + /// Is the first subpath closed? + pub fn closed(&self) -> bool { + self.closed + } + /// [`stops`] on this path. + pub fn stops(&self, n: usize, spacing: Option, start: f64) -> Vec<(Point, f64)> { + let (start, gap) = layout(self.len, self.closed, n, spacing, start); + (0..n).map(|i| self.spine.at_length(nth_length(self.len, self.closed, start, gap, i))).collect() + } + /// [`stop`] on this path: `None` for an `i` of `n` or more. + pub fn stop(&self, n: usize, spacing: Option, start: f64, i: usize) -> Option<(Point, f64)> { + if i >= n { + return None; + } + let (start, gap) = layout(self.len, self.closed, n, spacing, start); + Some(self.spine.at_length(nth_length(self.len, self.closed, start, gap, i))) + } } /// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the @@ -359,9 +396,7 @@ fn wrap(s: f64, len: f64) -> f64 { /// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them /// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length. pub fn stops(path: &PathData, n: usize, spacing: Option, start: f64) -> Option> { - let Measured { spine, len, closed } = Measured::new(path)?; - let (start, gap) = layout(len, closed, n, spacing, start); - Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect()) + Some(Measured::new(path)?.stops(n, spacing, start)) } /// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others. @@ -370,9 +405,7 @@ pub fn stop(path: &PathData, n: usize, spacing: Option, start: f64, i: usiz if i >= n { return None; } - let Measured { spine, len, closed } = Measured::new(path)?; - let (start, gap) = layout(len, closed, n, spacing, start); - Some(spine.at_length(nth_length(len, closed, start, gap, i))) + Measured::new(path)?.stop(n, spacing, start, i) } /// The distance along a path of length `len` (closed or not) to object `i`, from `start` and diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 844aa8c810..63951d8104 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -206,11 +206,13 @@ impl Document { impl Document { /// Visit every paint held by the art (opacity masks included), symbol definitions, pattern /// tiles and graphic styles (fills, strokes and text runs), and the colour of every - /// gradient-mesh point as a solid paint. + /// gradient-mesh point as a solid paint. A graph's default black legend fill is left out. pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { let mut tree = |n: &Node| { walk_with_masks(n, &mut |m| { - node_paints(m).chain(override_paints(m)).chain(graph_paints(m)).for_each(&mut *f); + node_paints(m).chain(override_paints(m)).for_each(&mut *f); + let default_legend = default_legend_fill(m); + graph_paints(m).enumerate().filter(|(i, _)| Some(*i) != default_legend).for_each(|(_, p)| f(p)); if let NodeKind::Mesh(mesh) = &m.kind { mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); } @@ -404,6 +406,19 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator Option { + let g = n.extras().graph.as_deref()?; + let l = &g.label_styles; + if l.legend.is_none() || !g.picks_legend_fill() { + return None; + } + let series: usize = g.series_paints.iter().map(|s| usize::from(s.fill.is_some()) + usize::from(s.stroke.is_some())).sum(); + Some(series + 2 * (usize::from(l.values.is_some()) + usize::from(l.categories.is_some()))) +} + /// Mutable [`node_paints`], in the same order. pub fn node_paints_mut(n: &mut Node) -> impl Iterator { let runs = match &mut n.kind { @@ -446,11 +461,16 @@ fn map_node(n: &Node, f: &mut PaintFn) -> Option<(Node, usize)> { } } // A graph's series and label paints, which its marks and labels take when it is drawn again. - let graph: Vec<(usize, Paint)> = graph_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect(); + // The default black legend fill, which is not the legend labels' own, stays black. + let default_legend = default_legend_fill(n); + let graph: Vec<(usize, Paint)> = + graph_paints(n).enumerate().filter(|(i, _)| Some(*i) != default_legend).filter_map(|(i, p)| f(p).map(|q| (i, q))).collect(); if !graph.is_empty() { count += graph.len(); let m = out.get_or_insert_with(|| n.clone()); if let Some(spec) = m.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + // A legend fill of the labels' own stays theirs when the change makes it black. + spec.label_styles.mark_own_legend_fill(); let mut graph = graph.into_iter().peekable(); for (i, p) in graph_paints_mut(spec).enumerate() { if graph.peek().is_some_and(|c| c.0 == i) @@ -720,6 +740,62 @@ mod tests { assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none)); } + #[test] + fn a_legend_fill_mapped_to_black_stays_the_labels_own() { + let (mut d, a, _) = doc(); + let red = linked(Color::rgb(1.0, 0.0, 0.0), "Brand"); + let spec = crate::graph::GraphSpec { + label_styles: crate::graph::LabelStyles { legend: Some(crate::CharStyle { fill: red, ..Default::default() }), ..Default::default() }, + ..Default::default() + }; + assert!(spec.label_styles.legend_fill_is_own() && !spec.label_styles.legend_fill_set, "not black, saved without the mark"); + d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec)); + let mut to_black = |c: &mut Color, s: &mut Option, _: &mut f32| { + s.as_deref() == Some("Brand") && { + *c = Color::BLACK; + *s = None; + true + } + }; + assert_eq!(d.map_solid_paints(&mut to_black), 2, "the rectangle's fill and the legend fill"); + let styles = d.node(a).unwrap().extras().graph.clone().unwrap().label_styles; + assert_eq!(styles.legend.unwrap().fill, Paint::solid(Color::BLACK)); + assert!(styles.legend_fill_set, "the black fill is still the labels' own"); + } + + #[test] + fn the_default_black_legend_fill_is_left_as_it_is() { + use crate::graph::GraphKind; + // Only a pie with Legends in Wedges picks its legend labels' fill; the other graphs draw the kept black. + for (kind, in_wedges, picks) in [(GraphKind::Pie, true, true), (GraphKind::Pie, false, false), (GraphKind::Column, true, false)] { + let (mut d, a, _) = doc(); + let black = || crate::CharStyle { fill: Paint::solid(Color::BLACK), size: 11.0, ..Default::default() }; + let spec = crate::graph::GraphSpec { + kind, + legend: true, + pie_legend_in_wedges: in_wedges, + series_paints: vec![crate::SeriesPaint { fill: Some(Paint::solid(Color::BLACK)), ..Default::default() }], + label_styles: crate::graph::LabelStyles { values: Some(black()), legend: Some(black()), ..Default::default() }, + ..Default::default() + }; + let blacks = |d: &Document| { + let mut seen = 0; + d.visit_paints(&mut |p| seen += usize::from(*p == Paint::solid(Color::BLACK))); + seen + }; + let before = blacks(&d); + d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec)); + let want = if picks { 2 } else { 3 }; + assert_eq!(blacks(&d) - before, want, "{kind:?}, in wedges {in_wedges}: the series, value labels' and legend fills visited"); + let mut link_black = |c: &mut Color, s: &mut Option, _: &mut f32| *c == Color::BLACK && s.replace("Black".into()).is_none(); + assert_eq!(d.map_solid_paints_in(&[a], &mut link_black), want, "{kind:?}, in wedges {in_wedges}"); + let styles = d.node(a).unwrap().extras().graph.clone().unwrap().label_styles; + let legend = if picks { Paint::solid(Color::BLACK) } else { linked(Color::BLACK, "Black") }; + assert_eq!(styles.legend.unwrap().fill, legend, "{kind:?}, in wedges {in_wedges}: linked unless the pie picks the fill"); + assert_eq!(styles.values.unwrap().fill, linked(Color::BLACK, "Black")); + } + } + #[test] fn mapping_within_ids_visits_each_paint_once() { let (mut d, a, b) = doc(); diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 3c8c1b324d..4877ee7b57 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -463,8 +463,13 @@ impl NineSliceMap { /// /// Strokes keep their weight, as they do for any instance. pub fn place(&self, art: &Node, doc: &Document) -> Node { - let boxes = doc.symbol_boxes().quick(); - self.place_at(art, &boxes, 0) + self.place_with(art, &doc.symbol_boxes().quick()) + } + + /// [`Self::place`] with the instances in `art` measured by `boxes`, quick boxes or boxes + /// measuring symbol art. + pub(crate) fn place_with(&self, art: &Node, boxes: &SymbolBoxes) -> Node { + self.place_at(art, boxes, 0) } /// [`Self::place`] without clip groups, for writers that do not apply clipping (DXF, WMF): @@ -480,16 +485,25 @@ impl NineSliceMap { /// object drawn in clip groups counts one group plus a clip path and a copy for each cell its /// box reaches. That is never more than [`MAX_SLICE_GROWTH`] for each object of `art`. pub fn most_placed(&self, art: &Node, doc: &Document) -> u64 { - let boxes = doc.symbol_boxes().quick(); - self.most_at(art, &boxes, 0) + self.most_placed_with(art, &doc.symbol_boxes().quick()) + } + + /// [`Self::most_placed`] with the instances in `art` measured by `boxes`, quick boxes or boxes + /// measuring symbol art. + pub(crate) fn most_placed_with(&self, art: &Node, boxes: &SymbolBoxes) -> u64 { + self.most_at(art, boxes, 0) } /// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one /// piece, with everything in it: the map of the cell its box lies in, or of the cell at the /// origin when it has no box. `None` when its box crosses a guide. pub fn one_cell(&self, n: &Node, doc: &Document) -> Option { - let boxes = doc.symbol_boxes().quick(); - let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + self.one_cell_with(n, &doc.symbol_boxes().quick()) + } + + /// [`Self::one_cell`] with the instances in `n` measured by `boxes`. + pub(crate) fn one_cell_with(&self, n: &Node, boxes: &SymbolBoxes) -> Option { + let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { return Some(self.cell_at(Point::ZERO)); }; let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); @@ -501,6 +515,11 @@ impl NineSliceMap { Self { clips: false, ..*self }.most_placed(art, doc) } + /// This map placing without clip groups, as [`Self::place_unclipped`] does. + pub(crate) fn unclipped(&self) -> Self { + Self { clips: false, ..*self } + } + /// [`Self::most_placed`] for `n` at nesting depth `depth`, following [`Self::place_at`]. fn most_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { let whole = n.count_with_masks() as u64; @@ -671,7 +690,7 @@ impl NineSliceMap { } /// `n` moved by `a`, its strokes at their own weight. -fn transformed(n: &Node, a: Affine) -> Node { +pub(crate) fn transformed(n: &Node, a: Affine) -> Node { let mut out = n.clone(); out.transform(a, false); out @@ -765,10 +784,7 @@ impl Document { /// `None` when the symbol has no guides or the instance is at its natural size. The guides cut /// the art's box with the instances in it measured by their own art. pub fn symbol_slice_map(&self, name: &str, xf: Affine) -> Option { - let guides = self.symbol_nine_slice(name)?; - let sym = self.symbols.iter().find(|s| s.name == name)?; - let art = self.symbol_boxes().quick().art_bounds(name).or_else(|| sym.art.geometric_bounds())?; - NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf) + self.symbol_boxes().quick().slice_map(name, xf) } /// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already @@ -782,9 +798,10 @@ impl Document { /// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through /// the guides could make more than [`MAX_SYMBOL_NODES`] objects ([`NineSliceMap::most_placed`]). pub fn place_symbol_art(&self, name: &str, art: &Node, xf: Affine) -> Option { - match self.symbol_slice_map(name, xf) { - Some(m) if m.most_placed(art, self) > MAX_SYMBOL_NODES => None, - Some(m) => Some(m.place(art, self)), + let boxes = self.symbol_boxes().quick(); + match boxes.slice_map(name, xf) { + Some(m) if m.most_placed_with(art, &boxes) > MAX_SYMBOL_NODES => None, + Some(m) => Some(m.place_with(art, &boxes)), None => Some(transformed(art, xf)), } } @@ -795,8 +812,8 @@ impl Document { pub fn edited_nine_slice(&self) -> Option { let se = self.symbol_edit.as_ref()?; let guides = self.symbol_nine_slice(&se.symbol)?; - let sym = self.symbols.iter().find(|s| s.name == se.symbol)?; - let art = self.symbol_boxes().quick().art_bounds(&se.symbol).or_else(|| sym.art.geometric_bounds())?; + let boxes = self.symbol_boxes().quick(); + let art = boxes.art_bounds(&se.symbol).or_else(|| boxes.symbol_art(&se.symbol)?.geometric_bounds())?; let natural = self.symbol_natural_xf(&se.symbol); let b = natural.transform_rect_bbox(art); if !finite(b) || b.width() <= 1e-9 || b.height() <= 1e-9 || natural.determinant().abs() < 1e-18 { @@ -811,8 +828,9 @@ impl Document { /// [`Self::place_symbol_art`] for writers that do not apply clipping /// ([`NineSliceMap::place_unclipped`]). pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option { - match self.symbol_slice_map(name, xf) { - Some(m) => (m.most_placed_unclipped(art, self) <= MAX_SYMBOL_NODES).then(|| m.place_unclipped(art, self)), + let boxes = self.symbol_boxes().quick(); + match boxes.slice_map(name, xf).map(|m| m.unclipped()) { + Some(m) => (m.most_placed_with(art, &boxes) <= MAX_SYMBOL_NODES).then(|| m.place_with(art, &boxes)), None => Some(transformed(art, xf)), } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 822dce49ea..944accfe4b 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -8,7 +8,8 @@ use std::sync::Arc; use vectorcraft_geom::{Affine, Rect}; -use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF}; +use crate::symbol_nine_slice::transformed; +use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, NineSliceMap, Node, NodeKind, SYMBOL_HALF}; /// The box a symbol instance is measured by when its symbol's art has no box /// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin @@ -128,6 +129,8 @@ impl Document { /// 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 { + #[cfg(test)] + SYMBOLS_READ.with(|c| c.set(c.get() + self.symbols.len())); 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); @@ -136,6 +139,12 @@ impl Document { } } +#[cfg(test)] +thread_local! { + /// How many symbols [`Document::symbol_names`] has read on this thread. + pub(crate) static SYMBOLS_READ: Cell = const { Cell::new(0) }; +} + impl<'a> SymbolBoxes<'a> { /// The document whose symbols these are. pub fn document(&self) -> &'a Document { @@ -437,8 +446,8 @@ impl<'a> SymbolBoxes<'a> { // 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.symbol_art(name)?; - let most = match self.doc.symbol_slice_map(name, linear) { - Some(m) => m.most_placed(art, self.doc), + let most = match self.slice_map(name, linear) { + Some(m) => self.in_art(|| m.most_placed_with(art, self)), None => art.count_with_masks() as u64, }; let used = self.sliced_placed.get(); @@ -455,6 +464,14 @@ impl<'a> SymbolBoxes<'a> { v.map(|v| moved.transform_rect_bbox(v)) } + /// [`Document::symbol_slice_map`] with the guides cutting the art box these boxes measure for + /// the symbol (its geometric part, which does not depend on the mode of the boxes). + pub(crate) fn slice_map(&self, name: &str, xf: Affine) -> Option { + let guides = self.doc.symbol_nine_slice(name)?; + let art = self.art_bounds(name).or_else(|| self.symbol_art(name)?.geometric_bounds())?; + NineSliceMap::new(guides, art, self.doc.symbol_natural_xf(name), xf) + } + /// The box [`Self::placed_visual_within`] returns outside quick boxes. fn place_and_measure(&self, name: &str, xf: Affine, most: u64) -> Option { self.place_and_count(name, xf, most).and_then(|(v, _)| v) @@ -464,10 +481,10 @@ impl<'a> SymbolBoxes<'a> { /// opacity masks included. `None` when the art is not placed. fn place_and_count(&self, name: &str, xf: Affine, most: u64) -> Option<(Option, u64)> { self.measure_art(name, |art| { - let art = match self.doc.symbol_slice_map(name, xf) { - Some(m) if m.most_placed(art, self.doc) > most => return None, - Some(m) => m.place(art, self.doc), - None => self.doc.place_symbol_art(name, art, xf)?, + let art = match self.slice_map(name, xf) { + Some(m) if self.in_art(|| m.most_placed_with(art, self)) > most => return None, + Some(m) => self.in_art(|| m.place_with(art, self)), + None => transformed(art, xf), }; Some((self.in_art(|| art.visual(Some(self))), art.count_with_masks() as u64)) }) @@ -689,6 +706,39 @@ mod tests { assert_eq!(s.sliced_placed.get(), 13); } + #[test] + fn measuring_9_slice_instances_reads_the_symbol_list_once() { + // 2,000 symbols, then a frame with guides and a symbol with guides holding the frame + // scaled 2 times across, each shown by 200 instances scaled by different amounts. + let mut d = Document::new(200.0, 200.0); + for i in 0..2000 { + let dot = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0); + symbol(&mut d, &format!("Dot {i}"), vec![dot]); + } + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0); + symbol(&mut d, "Panel", vec![frame]); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let wide = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + symbol(&mut d, "Holder", vec![wide]); + d.set_symbol_nine_slice("Holder", Some(crate::SymbolNineSlice::default())); + let scaled = |d: &mut Document, name: &str| -> Vec { + (1..=200).map(|i| instance(d, name, Affine::scale_non_uniform(1.0 + f64::from(i) / 100.0, 1.5))).collect() + }; + let (panels, holders) = (scaled(&mut d, "Panel"), scaled(&mut d, "Holder")); + // What each instance measures in boxes of its own. + let exact: Vec<_> = panels.iter().map(|n| n.visual_bounds_with(&d.symbol_boxes())).collect(); + let quick: Vec<_> = holders.iter().map(|n| n.visual_bounds_with(&d.symbol_boxes().quick())).collect(); + let read = || SYMBOLS_READ.with(Cell::get); + let before = read(); + let s = d.symbol_boxes(); + assert_eq!(panels.iter().map(|n| n.visual_bounds_with(&s)).collect::>(), exact); + let s = d.symbol_boxes().quick(); + assert_eq!(holders.iter().map(|n| n.visual_bounds_with(&s)).collect::>(), quick); + // Each set of boxes reads the list once to find symbols by name. + let symbols = d.symbols.len(); + assert!(read() - before <= 2 * symbols, "{} symbols read", read() - before); + } + #[test] fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 60cc3f2845..c5e11c648f 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::{BezPath, FillRule, PathData}; /// 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)> { - match clip_effects_applied(clip, true) { + match clip_effects_applied(clip, true, || true) { Some(m) => m.clip_outline(Some(&outline_text), &unite), None => clip.clip_outline(Some(&outline_text), &unite), } @@ -31,14 +31,26 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { /// 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_while(n, || true) } -/// [`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 { +/// [`with_clip_effects_applied`] that calls `take` before it visits each member of a group, layer +/// or compound path in `n`, at every depth: once `take` returns `false`, that member and the +/// members after it are not visited. The members of a group or layer that are not visited stay as +/// they are, with their effects. A compound path with geometry effects of its own still becomes a +/// path of its reshaped geometry. In a clipping path that does not paint, one whose fills or +/// strokes have geometry effects still becomes a group of one path per fill and stroke. In both +/// cases the paths in it that are not visited go +/// into that geometry as they are, and their own effects are not applied. +pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option { + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) +} + +/// [`with_clip_effects_applied_while`]; 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, take: fn() -> 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(), @@ -53,7 +65,10 @@ fn clip_effects_applied(n: &Node, items: bool) -> Option { // 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) + if !take() { + break; + } + if let Some(r) = clip_effects_applied(c, items && !compound, take) && 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); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 20b4f982a3..1b64971a3a 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -904,7 +904,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { /// 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 only takes the geometry effects in it into its geometry -/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects +/// ([`crate::clip::with_clip_effects_applied_while`]), and the members of a clipping group or +/// compound path count toward the limit below. A group or layer in a clipping path that has +/// geometry effects of its own is walked twice, once for its members' effects and once for its +/// own, and its members count each time. 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 @@ -917,7 +920,7 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { // 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) + && let Some(applied) = crate::clip::with_clip_effects_applied_while(first, DrawnFrame::take_member) { *first = Arc::new(applied); } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 877528d0bb..519323e5f0 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -849,6 +849,53 @@ fn nested_groups_with_effects_count_toward_the_depth_limit() { assert!(deepest.is_some_and(|d| crate::reshape::geometry_effects(&d).len() == 1), "the deepest member keeps its effect"); } +/// The members of a clipping path count toward the member limit: with room for two more members, +/// the third member of a clipping group keeps its own effect as it is. +#[test] +fn clipping_path_members_past_the_member_limit_stay_as_they_are() { + let member = |id: u64| { + let mut m = with_effect(square_at_100(), offset_10()); + m.id = NodeId(id); + Arc::new(m) + }; + let clipping = Node::group(NodeId(20), vec![member(2), member(3), member(4)]); + let mut clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clipping), Arc::new(square_at_100())], clip: true }); + // An enclosing call that has visited all the members it may but two. + let _outer = DrawnFrame::enter().unwrap(); + DRAWN.with(|d| d.set((1, crate::SYMBOL_ART_MAX_NODES - 2))); + members_as_drawn(None, &mut clipped); + let clipping = clipped.children().unwrap()[0].clone(); + let effects: Vec = clipping.children().unwrap().iter().map(|m| crate::reshape::geometry_effects(m).len()).collect(); + assert_eq!(effects, [0, 0, 1], "the first two members take their Offset Path, the third keeps it"); + let widths: Vec = clipping.children().unwrap().iter().map(|m| m.geometric_bounds().unwrap().width()).collect(); + assert!((widths[0] - 120.0).abs() < 0.5 && (widths[1] - 120.0).abs() < 0.5 && (widths[2] - 100.0).abs() < 0.5, "{widths:?}"); +} + +/// A compound clipping path with an effect of its own takes that effect when the member limit is +/// reached among its paths: with room for one more member, the first path (at 100 to 200) takes +/// its Offset Path, the second (at 300 to 400) goes in as it is, and the compound path's Offset +/// Path reshapes both, to 80 to 220 and 290 to 410. +#[test] +fn a_compound_clipping_path_takes_its_own_effect_at_the_member_limit() { + let member = |id: u64, x: f64| { + let path = Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), black()); + Arc::new(with_effect(path, offset_10())) + }; + let kind = NodeKind::Compound { children: vec![member(2, 100.0), member(3, 300.0)], rule: FillRule::NonZero }; + let clipping = with_effect(Node::new(NodeId(20), kind), offset_10()); + let mut clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clipping), Arc::new(square_at_100())], clip: true }); + // An enclosing call that has visited all the members it may but one. + let _outer = DrawnFrame::enter().unwrap(); + DRAWN.with(|d| d.set((1, crate::SYMBOL_ART_MAX_NODES - 1))); + members_as_drawn(None, &mut clipped); + let clipping = clipped.children().unwrap()[0].clone(); + assert!(crate::reshape::geometry_effects(&clipping).is_empty(), "the compound path's Offset Path is in its geometry"); + let subpaths = clipping.path_data().unwrap().subpaths.clone(); + let bounds: Vec<(f64, f64)> = subpaths.into_iter().map(|sp| PathData::single(sp).bounds().unwrap()).map(|b| (b.x0, b.x1)).collect(); + let near = |(a, b): (f64, f64), (x0, x1): (f64, f64)| (a - x0).abs() < 0.5 && (b - x1).abs() < 0.5; + assert!(bounds.len() == 2 && near(bounds[0], (80.0, 220.0)) && near(bounds[1], (290.0, 410.0)), "{bounds:?}"); +} + /// An image or a symbol instance that a group's Transform effect moves takes the placed gradients /// of its own fills along, as a path does, and each copy takes them as many times as it takes the /// transform. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 3d74ac4fe0..e7e74972e6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -84,8 +84,8 @@ pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry, - has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, - reshape, + has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outer_effects, outline_art, + outline_text, reshape, }; pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index f0d2601d1e..5e86dafe96 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -107,11 +107,44 @@ fn reaches_appearance(e: &Effect) -> bool { } /// Is `n` a path or compound path that its Transform effects make into art of separate objects -/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`]), or whose -/// live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, baked and -/// expanded through that art ([`reshape`]). +/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`], [`copied_raster_at`]), +/// or whose live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, +/// baked and expanded through that art ([`reshape`]). pub fn has_transform_art(n: &Node) -> bool { - has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n) + has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n) || copied_raster_at(n).is_some() +} + +/// Where the own effects of `n`, a path or compound path, split so that each copy of a Transform +/// effect carries the raster effects listed before that effect ([`copied_raster_art`]): the index +/// of the last visible Transform effect that makes copies, when a visible raster effect that +/// follows a Transform effect ([`crate::raster::follows`]) is listed before it and every other +/// visible effect before it is a geometry effect or such a raster effect. `None` otherwise, and for +/// a path with a linked opacity mask, a 3D effect, Crop Marks, Vector Halftone, a live-effect +/// plug-in that paints what it returns, or a fill or stroke that paints with geometry effects of +/// its own. +pub(crate) fn copied_raster_at(n: &Node) -> Option { + if !matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) { + return None; + } + let effects = &n.appearance.effects; + // Checked first: this runs for every object drawn. + if !effects.iter().any(|e| e.visible && crate::raster::follows(&e.id)) || !effects.iter().any(|e| e.visible && e.id == "distort.transform") { + return None; + } + let t = effects.iter().rposition(|e| { + e.visible && e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0 + })?; + let before = effects.get(..t)?; + let carried = before.iter().any(|e| e.visible && crate::raster::follows(&e.id)) + && before.iter().all(|e| !e.visible || is_geometry(&e.id) || crate::raster::follows(&e.id)); + let excluded = n.extras().mask.as_ref().is_some_and(|m| m.linked) + || crate::has_solid(n) + || crate::has_revolve(n) + || crate::has_crop_marks(n) + || crate::addons::halftone::has_halftone(n) + || has_painted_effects(n) + || n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())); + (carried && !excluded).then_some(t) } /// Is `e` a visible live-effect plug-in whose manifest declares `paintOutput`: its id's plug-in id, @@ -797,6 +830,9 @@ fn as_art_opts( nodes: &SymbolNodes, apart: bool, ) -> Option { + if let Some(t) = copied_raster_at(n) { + return Some(dressed_keeping_effects(copied_raster_art(paint_doc, n, t, apart), n)); + } let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -882,6 +918,89 @@ pub(crate) fn dressed(mut art: Node, n: &Node) -> Node { art } +/// [`dressed`] that keeps the effects of `art`. +fn dressed_keeping_effects(mut art: Node, n: &Node) -> Node { + let effects = std::mem::take(&mut art.appearance.effects); + let mut art = dressed(art, n); + art.appearance.effects = effects; + art +} + +/// The art of path or compound path `n` whose own effects split at `t` ([`copied_raster_at`]): the +/// art the effects before the Transform effect at `t` make of `n`, with their raster effects, then +/// copied by that Transform effect, each copy an object of its own carrying those raster effects +/// moved, turned, reflected and scaled with it ([`follow_art`]), and then reshaped by the geometry +/// effects after it. The art keeps `n`'s id and carries the raster effects from `t` on, without +/// `n`'s name, visibility, transparency and opacity mask. The copies stop where [`copy_art`]'s do, +/// each with its raster effects. With `apart`, the Transform effects before `t` make each copy an +/// object of its own ([`copy_leaf`]). +fn copied_raster_art(doc: Option<&Document>, n: &Node, t: usize, apart: bool) -> Node { + let (before, rest) = n.appearance.effects.split_at(t); + let mut source = n.clone(); + source.appearance.effects = before.to_vec(); + let piece = match copied_raster_at(&source) { + Some(at) => copied_raster_art(doc, &source, at, apart), + None => { + let mut art = one_piece(&source); + let carry = reshape_leaves_apart(&mut art, &geometry_effects(&source), &source.appearance.effects, Budget::EFFECTS, apart, doc); + if apart { + hoist_copies(&mut art); + } + transform_items(&mut art, doc); + let mut ran = source.appearance.effects.clone(); + if carry.skipped() { + carry.hide_skipped(&mut ran); + } + art.appearance.effects = crate::raster::without_geometry(&ran); + art + } + }; + let mut art = Node::group(n.id, vec![Arc::new(piece)]); + if let (Some(e), Some(bounds)) = (rest.first(), art.geometric_bounds()) { + let params = merged_params(&e.id, &e.params); + let (m, copies) = crate::distort::transform_map(bounds, ¶ms, n.id.0); + let options = crate::distort::TransformOptions::of(¶ms); + // The raster effects follow the copies below; Scale Strokes & Effects scales the others. + let mut scaling = copy_scaling(options); + scaling.effects = options.strokes.then_some(scale_unfollowed as fn(&mut Effect, f64)); + let max = Budget::EFFECTS.at_least(art_cost(&art)); + copy_art(&mut art, m, copies, scaling, max); + if let Some(children) = art.children_mut() { + // The original is last, and each copy above it is mapped once more by `m`. + let step = m.with_translation(vectorcraft_geom::Vec2::ZERO); + let mut a = Affine::IDENTITY; + let none = HashSet::new(); + for (k, c) in children.iter_mut().rev().enumerate() { + if k > 0 { + a *= step; + follow_art(Arc::make_mut(c), a, &none, Some(&none), None, 0); + } + // Each copy is the piece in a group of its own: the piece takes its place. + if let Some([one]) = c.children().map(Vec::as_slice) { + *c = one.clone(); + } + } + } + } + let later: Vec<&Effect> = rest.iter().skip(1).filter(|e| e.visible && is_geometry(&e.id)).collect(); + let carry = reshape_leaves_apart(&mut art, &later, &n.appearance.effects, Budget::EFFECTS, apart, doc); + let mut ran = n.appearance.effects.clone(); + if carry.skipped() { + carry.hide_skipped(&mut ran); + } + art.appearance.effects = crate::raster::without_geometry(ran.get(t..).unwrap_or_default()); + art +} + +/// The effects of `n` that paint around its whole art: all of its own, or from the Transform effect +/// on for a path whose copies each carry the raster effects listed before it ([`copied_raster_at`]). +pub fn outer_effects(n: &Node) -> &[Effect] { + match copied_raster_at(n) { + Some(t) => n.appearance.effects.get(t..).unwrap_or_default(), + None => &n.appearance.effects, + } +} + /// A group with `n`'s id holding `n` without its transparency, opacity mask and effects: the one /// piece that `n`'s own geometry effects reshape. fn one_piece(n: &Node) -> Node { @@ -909,6 +1028,9 @@ pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option { return None; } + if let Some(t) = copied_raster_at(n) { + return Some(dressed_keeping_effects(copied_raster_art(doc, n, t, false), n)); + } if n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) { // One element: its pieces never knock each other out. return crate::bake_type_appearance(doc, n).map(|art| Node { knockout: vectorcraft_doc::Knockout::Off, ..art }); diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 92057f1c7f..e9a8f2a0a7 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -663,10 +663,11 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// instances, blends and clipping groups already resolved), in document coordinates: with the /// 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`. 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> { +/// each member, about one shared axis. With the effect on the object, group or layer, also the +/// shadow drawn behind them. The swatches and patterns named by a live-effect plug-in 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 { 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); @@ -682,7 +683,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node) -> Result, n: &Node, fx: &Effect, shapes: &[Profile]) -> let art = surfaces_for(doc, n, fx, shapes)?; // A shadow past the limits draws the source path, as a solid past them does; commands report // the error ([`validate_solid_in`]). - let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? { - Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)), - None => vec![], - }; + let shadow = shadow_for(n.id, fx, shapes).ok()?; Some((shadow, art)) } +/// The shadow [`art_for`] draws for effect `fx` on `shapes` behind the surfaces of object `id`: +/// none with the shadow off, and an error when it is past the limits. +pub(crate) fn shadow_for(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Result>, String> { + Ok(match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes)? { + Some((caster, settings, cast)) => shadow::scoped_art(id, &caster, settings, cast, has_masks(shapes)), + None => vec![], + }) +} + /// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on /// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the /// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 1e19a21c15..410d51443f 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -53,8 +53,8 @@ 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 (faces, shadow) = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? }; + let faces: Vec = after_geometry(doc, n, faces, shadow, 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")); @@ -63,13 +63,14 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, n: &Node, faces: Vec, revolve: bool) -> Vec { +/// ([`reshaped_beside`]): its first visible Revolve when `revolve` is set, else its first visible +/// Extrude & Bevel, Inflate or Rotate. The effects measure the faces together with `shadow`, the +/// shadow the canvas draws behind them, as they measure the drawn art. +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, shadow: 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) }) + reshaped_beside(faces, shadow, 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 @@ -86,6 +87,15 @@ pub(crate) struct WireFace { /// 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 { + reshaped_beside(faces, vec![], later, ctx) +} + +/// The name the faces carry through [`reshaped_beside`], which tells them from the shadow. +const FACE: &str = "face"; + +/// [`reshaped`] with `shadow`, the shadow drawn behind the faces, in the art the effects reshape: +/// each effect measures the faces and the shadow together, and only the faces are returned. +fn reshaped_beside(faces: Vec, shadow: 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; @@ -95,29 +105,44 @@ pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext 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 face = Node::path(id, f.path, Appearance { items, ..Default::default() }); + face.name = Some(FACE.into()); + Arc::new(face) }; - let mut art = Node::group(id, faces.into_iter().map(face).collect()); + let mut art = Node::group(id, shadow.into_iter().chain(faces.into_iter().map(face)).collect()); crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); let mut out = vec![]; - 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) }); - } - }); + face_paths(&art, false, &mut out); out } +/// The faces in `n` ([`FACE`]), `inside` one or not, each with the width of its stroke: a face that +/// a Transform effect copies or a live-effect plug-in cuts into pieces becomes a group with its name. +fn face_paths(n: &Node, inside: bool, out: &mut Vec) { + let inside = inside || n.name.as_deref() == Some(FACE); + if inside && let Some(path) = n.path_data() { + out.push(WireFace { path: path.clone(), stroke: n.appearance.stroke().map_or(0.0, |s| s.width) }); + } + for c in n.children().into_iter().flatten() { + face_paths(c, inside, out); + } +} + +/// The faces of a 3D effect on an object, group or layer itself, and the shadow the canvas draws +/// behind them (none with the shadow off, or with the effect on fills and strokes). +pub(crate) type FacesAndShadow = (Vec, Vec>); + /// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect /// 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> { +/// the solids of its members, about one shared axis. With the effect on the object, group or layer, +/// also the shadow drawn behind them. 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 { 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))?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); - return facet_paths(fx, &shapes); + return Ok((facet_paths(fx, &shapes)?, super::shadow_for(n.id, fx, &shapes)?)); } let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); let (path, rule) = object_profile(n, label)?; @@ -125,7 +150,8 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S let (sources, paints) = solid_paint_sources(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); - return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); + let shapes = [Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]; + return Ok((facet_paths(fx, &shapes)?, super::shadow_for(n.id, fx, &shapes)?)); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; @@ -136,7 +162,7 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } } - Ok(out) + Ok((out, vec![])) } /// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 3d92d3d277..735be60d6b 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -222,6 +222,32 @@ fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { } } +/// A Transform effect after the 3D effect scales the faces about the center of the drawn art with +/// its shadow, as it scales the surfaces on the canvas. +#[test] +fn a_later_transform_measures_the_faces_with_their_shadow() { + for id in [super::super::EXTRUDE, crate::REVOLVE] { + let scale = crate::new_effect("distort.transform", &json!({"scaleH": 50.0, "scaleV": 50.0})).unwrap(); + let mut n = solid_square(id, json!({"shadow": true, "shadowDistance": 100})); + n.appearance.effects.push(scale.clone()); + let wire = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}"))); + let art = if id == crate::REVOLVE { crate::revolve_art(&n) } else { super::super::solid_art(&n) }.unwrap(); + let mut drawn = art.clone(); + let fx: Vec<&Effect> = art.appearance.effects.iter().collect(); + crate::reshape::reshape_leaves(&mut drawn, &fx, &art.appearance.effects, None); + // The shadow is the art's first member; the surfaces follow it. + let surfaces: Vec = drawn.children().unwrap().iter().skip(1).flat_map(|c| face_paths(c)).collect(); + let a = bounds(&surfaces); + for (x, y) in [(wire.x0, a.x0), (wire.y0, a.y0), (wire.x1, a.x1), (wire.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{id}: {wire:?} {a:?}"); + } + // Without the shadow, the faces are scaled about their own center. + let mut alone = solid_square(id, json!({})); + alone.appearance.effects.push(scale); + assert!((bounds(&three_d_wireframe_in(None, &alone).unwrap()).x0 - wire.x0).abs() > 1.0, "{id}"); + } +} + /// With two 3D effects, a geometry effect between them reshapes the faces of the one drawn only /// once: Revolve is drawn, so a Transform before it moves its profile and not its faces again. #[test] diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index a610ae7c71..1ebcab785a 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -584,8 +584,9 @@ fn image(id: u64, effects: Vec) -> Node { /// A group's Transform effect transforms, with Scale Strokes & Effects off or on, the raster /// effects that a member's own Transform effect with copies holds back: they keep their value /// through the member's effect and transform with the group's. That holds for a shadow listed -/// before the member's copying effect, one listed after it, a fill's shadow under it, the shadow -/// of a member of a nested group with such an effect, and an image's shadow. +/// before the member's copying effect (which each copy carries), one listed after it, a fill's +/// shadow under it, the shadow of a member of a nested group with such an effect, and an image's +/// shadow. #[test] fn a_groups_transform_moves_the_shadows_its_members_copies_hold_back() { let copies = || transform(json!({"moveH": 60, "copies": 1})); @@ -605,7 +606,8 @@ fn a_groups_transform_moves_the_shadows_its_members_copies_hold_back() { let art = crate::evaluate_container(None, &g).unwrap(); for id in [71, 72, 73, 75, 76] { let drawn = shadows_in(&node_in(&art, id)); - assert!(near(&drawn, &[want]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}"); + let wanted = if id == 71 { vec![want, want] } else { vec![want] }; + assert!(near(&drawn, &wanted), "Scale Strokes & Effects {strokes}: {id} {drawn:?}"); } } } @@ -841,3 +843,93 @@ fn copying_and_object_disabled_transforms_keep_their_existing_shadow_rules() { assert!(close(drawn_shadow(&effects), (10.0, 5.0, 4.0)), "{t:?}: {:?}", drawn_shadow(&effects)); } } + +/// The blur of every Drop Shadow drawn on `n` and the nodes under it, in the order of +/// [`shadows_in`]. +fn shadow_blurs_in(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + for r in raster_effects(list) { + if let RasterFx::DropShadow { blur, .. } = r { + out.push(blur); + } + } + } + }); + out +} + +/// How many paths `n` holds. +fn paths_in(n: &Node) -> usize { + let mut count = 0; + n.walk(&mut |m| count += usize::from(matches!(m.kind, vectorcraft_doc::NodeKind::Path { .. }))); + count +} + +/// A Transform effect that makes copies gives each copy the shadow listed before it, moved, turned, +/// reflected and scaled with that copy, the last copy lowest: drawn, expanded and baked. The object +/// itself keeps the shadow as it is, and paints none around all of them. With Scale Strokes & +/// Effects on, the shadow is not scaled a second time. +#[test] +fn each_copy_of_a_transform_effect_carries_the_shadow_listed_before_it() { + let cases = [ + (json!({"moveH": 100, "copies": 2}), vec![(10.0, 0.0), (10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0, 4.0]), + // A turn of 90° counterclockwise on screen takes an offset to the right to one upward. + (json!({"rotate": 90, "copies": 2}), vec![(-10.0, 0.0), (0.0, -10.0), (10.0, 0.0)], vec![4.0, 4.0, 4.0]), + (json!({"reflectX": true, "moveH": 100, "copies": 1}), vec![(-10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0]), + (json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1}), vec![(20.0, 0.0), (10.0, 0.0)], vec![8.0, 4.0]), + (json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}), vec![(20.0, 0.0), (10.0, 0.0)], vec![8.0, 4.0]), + (json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "random": true}), vec![(10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0]), + ]; + for (params, want, blurs) in cases { + let n = square(91, vec![shadow(10.0, 0.0, 4.0), transform(params.clone())]); + assert!(crate::has_transform_art(&n), "{params}"); + assert!(crate::outer_effects(&n).iter().all(|e| e.id != "stylize.dropShadow"), "{params}"); + let random = params["random"].as_bool().unwrap_or(false); + for (how, art) in + [("drawn", crate::reshape(None, &n, None).unwrap()), ("expanded", expand_outlined(None, &n, None).unwrap()), ("baked", baked(n.clone()))] + { + let drawn = shadows_in(&art); + assert_eq!(paths_in(&art), want.len(), "{params}: {how} {art:?}"); + if random { + // Random scales the copy by a share of the 200 %, and its shadow with it; the object + // keeps its own. + assert_eq!(drawn.len(), want.len(), "{params}: {how} {drawn:?}"); + assert!(near(&drawn[1..], &want[1..]), "{params}: {how} {drawn:?}"); + continue; + } + assert!(near(&drawn, &want), "{params}: {how} {drawn:?}"); + let b = shadow_blurs_in(&art); + assert!(b.len() == blurs.len() && b.iter().zip(&blurs).all(|(x, y)| (x - y).abs() < 1e-9), "{params}: {how} blurs {b:?}"); + } + } +} + +/// A shadow listed after a Transform effect that makes copies paints once around all of them, and +/// one listed before it on each copy. A Transform effect that makes no copies, or has Transform +/// Objects off, leaves the object's shadows on the object. +#[test] +fn a_shadow_after_a_copying_transform_paints_once_around_the_copies() { + let n = square(92, vec![shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90, "copies": 1})), shadow(0.0, 5.0, 0.0)]); + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(0.0, 5.0), (0.0, -10.0), (10.0, 0.0)]), "{:?}", shadows_in(&art)); + assert_eq!(crate::outer_effects(&n).len(), 2); + for params in [json!({"rotate": 90}), json!({"rotate": 90, "copies": 1, "objects": false})] { + let n = square(93, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]); + assert!(!crate::has_transform_art(&n), "{params}"); + assert_eq!(crate::outer_effects(&n).len(), 2, "{params}"); + } +} + +/// Copies that stop at the effect budget each keep their shadow: no copy goes without one, and no +/// shadow is left without its copy. +#[test] +fn copies_past_the_budget_each_keep_their_shadow() { + let n = path_node(94, wavy(30_000), vec![shadow(1.0, 0.5, 0.0), transform(json!({"moveV": 50, "copies": 1000}))]); + let art = crate::reshape(None, &n, None).unwrap(); + let paths = paths_in(&art); + assert!(paths > 1 && paths < 1001, "{paths}"); + assert_eq!(shadows_in(&art).len(), paths); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b46678804b..0fccbcdecb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -53,7 +53,7 @@ heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], op flate2 = { workspace = true } chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). -craft-xml = { workspace = true } +roxmltree = "0.21" # The compound file that holds a Word 97-2003 document placed as type (place/doc.rs). cfb = "0.15" kurbo = { workspace = true } diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 00d75e9888..a13110cf67 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -871,13 +871,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { let named: std::collections::HashSet = ids.iter().copied().collect(); let threads = thread_index(d); let mut done = std::collections::HashSet::new(); + // The threads already found not to be a story whose frames are all among them: their + // other frames are read one at a time without going through the thread again. + let mut partial = std::collections::HashSet::new(); let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![]; for &id in &ids { if !done.insert(id) { continue; } - let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) { - Some(thread) => crate::cmd::threads::story_in(d, thread, id), + let thread = threads.get(&id).copied(); + let story = match thread.filter(|i| !partial.contains(i)).and_then(|i| d.text_threads.get(i)) { + Some(frames) => crate::cmd::threads::story_in(d, frames, id), None => vec![], }; if !story.is_empty() && story.iter().all(|f| named.contains(f)) { @@ -885,6 +889,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { // 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) { + // `id` is type, so the story of its thread is the same for each of its frames. + partial.extend(thread); out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk))); } } diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index c9ed79122c..9671bb3306 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -379,6 +379,38 @@ fn remove_breaks_reads_the_threads_once() { } } +/// Remove Breaks reads each named frame of a story that is not all named on its own, in the order +/// named and each once, as one undo step, and goes through that story's frames once for all of them. +#[test] +fn remove_breaks_goes_through_a_partly_named_story_once() { + for (kind, brk) in [("line", '\u{2028}'), ("paragraph", '\n')] { + let mut s = session(); + let a = text(&mut s, json!({"x": 10, "y": 20, "text": "a\u{2028}b\n".repeat(40), "size": 12, "area": {"width": 150, "height": 60}})); + let mut roots = vec![a]; + for i in 0..4 { + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 100 + 70 * i, "width": 150, "height": 60})).unwrap(); + roots.push(NodeId(r["id"].as_u64().unwrap())); + } + select(&mut s, &roots); + s.execute("text.thread.create", &json!({})).unwrap(); + let f = s.doc().unwrap().doc.text_threads[0].clone(); + assert_eq!(f.len(), 5); + let named = [f[4], f[1], f[3]]; + let breaks: Vec = named.iter().map(|id| obj(&s, *id).plain_text().matches(brk).count()).collect(); + assert!(breaks.iter().all(|n| *n > 0), "{kind}: each named frame holds breaks: {breaks:?}"); + let before = doc_json(&s); + let steps = undo_steps(&s); + let builds = crate::cmd::threads::story_builds(); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": kind, "ids": [f[4].0, f[1].0, f[4].0, f[3].0]})).unwrap(); + assert_eq!(crate::cmd::threads::story_builds() - builds, 1, "{kind}: the story's frames are gone through once"); + assert_eq!(r["removed"].as_u64(), Some(breaks.iter().sum::() as u64), "{kind}"); + assert_eq!(ids(&r), named, "{kind}"); + assert_eq!(undo_steps(&s), steps + 1, "{kind}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before, "{kind}"); + } +} + /// 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, _| { diff --git a/crates/engine/src/cmd/artboardalign.rs b/crates/engine/src/cmd/artboardalign.rs index 541ec94a4e..a3e585a8a0 100644 --- a/crates/engine/src/cmd/artboardalign.rs +++ b/crates/engine/src/cmd/artboardalign.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::Scaling; use vectorcraft_geom::{Rect, Vec2}; use super::object::choice; -use super::panelcmds::{artboard_index, artboards_art, locked_and_hidden, named_or_selected, note_artboards}; -use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, switch_or}; +use super::panelcmds::{artboard_at, artboards_art, locked_and_hidden, named_or_selected, note_artboards, on_board}; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, index, switch_or}; use crate::EngineError; pub fn specs() -> Vec { @@ -63,13 +63,7 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result { check_switches(p, &["patterns"], cmd)?; let to_artboard = choice(p, cmd, "to", &["selection", "artboard"])? == Some("artboard"); let indices = named_or_selected(s, p, cmd)?; - let given = |key: &str| -> Result> { - match p.get(key).filter(|v| !v.is_null()) { - Some(v) => artboard_index(&s.doc()?.doc, &json!({ "index": v }), cmd).map(Some), - None => Ok(None), - } - }; - let (named, recorded) = (given("artboard")?, given("activeArtboard")?); + let (named, recorded) = (artboard_at(&s.doc()?.doc, p, "artboard", cmd)?, artboard_at(&s.doc()?.doc, p, "activeArtboard", cmd)?); let move_art = switch_or(p, "moveArt", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true), cmd)?; let locked_art = locked_and_hidden(s, p, move_art, cmd)?; note_artboards(s, p, &indices); @@ -78,8 +72,8 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result { (false, _) => None, (true, Some(i)) => Some(i), (true, None) => { - let active = s.tool_options_of("artboard")["active"].clone(); - Some(artboard_index(&s.doc()?.doc, &json!({ "index": active }), cmd)?) + let active = s.tool_options_of("artboard")["active"].as_u64().map(index); + Some(active.map_or_else(|| Err(EngineError::Other("no such artboard".into())), |i| on_board(&s.doc()?.doc, i))?) } }; if let Some(i) = reference.filter(|_| named.is_none()) { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 06ec06e348..038a4202a6 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "Live Paint Info", [], None, - "{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}", + "{group?|id?: Live Paint group id (one required; id is read when group is left out)} → {faces: [{id, fill (top fill), bounds, appearance: [row…]}], edges: [{id, stroke (top stroke), width (top stroke's), appearance: [row…]}], where a row is {index (in the part's fill and stroke stack, bottom first), kind: \"fill\"|\"stroke\", paint, opacity: 0..1, visible, width?: pt (strokes only), drawn (the part is shown with opacity above 0, and the row is shown, has paint and opacity above 0, a stroke also width above 0)}, sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}", has_doc, lp_info ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 9c3c750996..1554ef7328 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,8 @@ use super::{ - 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, + Appearance, AppearanceItem, 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. @@ -524,6 +525,37 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { Ok(json!({ "group": g.0, "edge": edge.0 })) } +/// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A +/// row is `drawn` when the part is shown with an opacity above 0 and the row is shown, has paint +/// and has an opacity above 0 (a stroke also a weight above 0): the rule of [`draws`], row by row. +fn appearance_rows(n: &Node) -> Vec { + let part_shown = n.visible && n.opacity > 0.0; + n.appearance + .items + .iter() + .enumerate() + .map(|(i, item)| { + let shown = part_shown && item.visible() && !item.paint().is_none() && item.opacity() > 0.0; + let mut row = json!({ + "index": i, + "kind": item.kind_name(), + "paint": item.paint().label(), + "opacity": item.opacity(), + "visible": item.visible(), + }); + let drawn = match item { + AppearanceItem::Fill(_) => shown, + AppearanceItem::Stroke(st) => { + row["width"] = json!(st.width); + shown && st.width > 0.0 + } + }; + row["drawn"] = json!(drawn); + row + }) + .collect() +} + pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result { let g = match object_param(p, "group", "livePaint.info")? { Some(g) => g, @@ -535,12 +567,24 @@ pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result { .iter() .map(|f| { let bb = f.geometric_bounds().unwrap_or(Rect::ZERO); - json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] }) + json!({ + "id": f.id.0, + "fill": f.appearance.fill_paint().label(), + "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], + "appearance": appearance_rows(f), + }) }) .collect(); let edges: Vec = b::edges(n) .iter() - .map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() })) + .map(|e| { + json!({ + "id": e.id.0, + "stroke": e.appearance.stroke_paint().label(), + "width": e.appearance.stroke_width(), + "appearance": appearance_rows(e), + }) + }) .collect(); let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0); let options = n.gap_options(); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 4e4da57f9c..8bf6841e17 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -686,8 +686,15 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color } } let styles = &mut g.label_styles; - for st in [&mut styles.values, &mut styles.categories, &mut styles.legend].into_iter().flatten() { - map_paint_colors(&mut st.fill, f); + // A legend fill of the labels' own stays theirs when `f` makes it black. The default black legend fill stays black, + // so the labels on dark pie wedges are still drawn white. + styles.mark_own_legend_fill(); + let own = styles.legend_fill_is_own(); + for (st, fill) in [(&mut styles.values, true), (&mut styles.categories, true), (&mut styles.legend, own)] { + let Some(st) = st else { continue }; + if fill { + map_paint_colors(&mut st.fill, f); + } map_paint_colors(&mut st.stroke, f); } for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() { diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index cac6f4bdcb..da310a89b9 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -671,10 +671,11 @@ fn a_document_replaced_in_the_back_is_measured_before_it_comes_to_the_front() { } /// Type a document replaced in the back sets in a composite font that only the front document -/// defines is measured in that font while the front one is registered, and again, as missing, when -/// the replaced document comes to the front. +/// defines is measured as missing, though the front one is registered, also when the replaced +/// document defines no composite font, and still as missing when it comes to the front. #[test] -fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() { +fn a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing() { + let db = vectorcraft_text::FontDb::global(); let mut s = session(); let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Lacked"})).unwrap()["id"].as_u64().unwrap(); let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); @@ -682,8 +683,16 @@ fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() { s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Lacked")]})).unwrap(); assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); let back = text_in(&s, 0, id); - let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &back).bounds; - assert_eq!(back.cached_bounds, Some(expected)); + assert!(s.documents()[0].doc.composite_fonts.is_empty()); + let in_front = vectorcraft_text::layout(db, &back).bounds; + // The same type set in a family nothing defines. + let mut nowhere = back.clone(); + for r in &mut nowhere.runs { + r.style.font_family = "No Such Family CF Lacked".into(); + } + let missing = vectorcraft_text::layout(db, &nowhere).bounds; + assert!((in_front.width() - missing.width()).abs() > 1e-6, "the front document's font draws otherwise"); + assert_eq!(back.cached_bounds, Some(missing)); assert!(s.set_active(0)); assert!(vectorcraft_text::FontDb::global().composite_font("CF Lacked").is_none()); let actual = text(&s, id); diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index ce067ddc9c..ffa11e362c 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -110,7 +110,7 @@ pub fn specs() -> Vec { "Smooth", [], None, - "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}", + "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag, keeping a corner where the path turns by more than 57.5° at the anchor and over radius and twice radius on either side → {ids, before, after}", has_selection, smooth_region ), diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 9f119fca86..be7db9a1ae 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -13,9 +13,237 @@ use super::{ /// of path within the reach on either side of it. VectorCraft's own value. const SMOOTH_SPREAD: f64 = 1.0 / 3.0; +/// The degrees by which a path must turn at an anchor, and over the tool's reach and twice its reach +/// on either side of it, for the Smooth tool to keep the anchor as a corner ([`smooth_corners`]): a +/// turn of more than 57.5°, an angle inside the corner of less than 122.5°. VectorCraft's own value, +/// the same on every drag and at any Fidelity. +const SMOOTH_CORNER_DEG: f64 = 57.5; + +/// Most pieces a curved segment is cut into evenly, and most more pieces near each of its ends, to +/// measure lengths along it in [`smooth_corners`]. +const CORNER_PIECES: usize = 64; + +/// The control polygon's length of `c`, which is at least its length. +fn polygon_len(c: CubicBez) -> f64 { + c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) +} + +/// The places (0 to 1, in order, without 0 and with 1) on the curved segment `c` that +/// [`smooth_corners`] measures it at: up to [`CORNER_PIECES`] evenly spaced, and near each end, over +/// a stretch at least three times `reach` long where the segment is that long, up to as many more +/// that are no more than `reach` / 8 apart along the control polygon, and as many again in each +/// stretch twice as long after it until the pieces reach three times `reach`. The turns over `reach` and +/// twice `reach` from an anchor are then measured along the curve however long the segment is. +fn corner_places(c: CubicBez, reach: f64) -> Vec { + let piece = reach / 8.0; + let pieces = |len: f64| if piece > 0.0 { (len / piece).ceil().clamp(1.0, CORNER_PIECES as f64) as usize } else { 1 }; + let k = pieces(polygon_len(c)).max(4); + let mut places: Vec = (1..=k).map(|j| j as f64 / k as f64).collect(); + if piece > 0.0 { + for from_start in [true, false] { + // The stretch from the end: halved until its control polygon is at most six reaches long. + let first = 1.0 / k as f64; + let mut t = first; + let part = |t: f64| if from_start { c.subsegment(0.0..t) } else { c.subsegment(1.0 - t..1.0) }; + for _ in 0..60 { + if polygon_len(part(t)) <= 6.0 * reach { + break; + } + t /= 2.0; + } + let side = |u: f64| if from_start { u } else { 1.0 - u }; + let m = pieces(polygon_len(part(t))); + let mut fine: Vec = (1..=m).map(|j| t * j as f64 / m as f64).collect(); + // Then the stretches twice as long in turn, cut as finely, up to the first even place, until + // the pieces reach three reaches from the end, so twice the reach is measured along the curve + // where a handle that does not reach out makes the curve start more slowly than its polygon. + let mut covered = 0.0; + let mut at = c.eval(side(0.0)); + for u in &fine { + let q = c.eval(side(*u)); + covered += at.distance(q); + at = q; + } + let (mut from, mut levels) = (t, 0); + while covered < 3.0 * reach && from < first && levels < 60 { + let to = (2.0 * from).min(first); + let piece_len = polygon_len(if from_start { c.subsegment(from..to) } else { c.subsegment(1.0 - to..1.0 - from) }); + let m = pieces(piece_len); + for j in 1..=m { + let u = from + (to - from) * j as f64 / m as f64; + let q = c.eval(side(u)); + covered += at.distance(q); + at = q; + fine.push(u); + } + (from, levels) = (to, levels + 1); + } + places.extend(fine.into_iter().map(side)); + } + } + places.retain(|u| *u > 0.0 && *u <= 1.0); + places.sort_by(f64::total_cmp); + places.dedup(); + places +} + +/// The degrees between the directions `u` and `v`, `None` when either has no length or is not a +/// number. +fn turn_between(u: kurbo::Vec2, v: kurbo::Vec2) -> Option { + let (lu, lv) = (u.hypot(), v.hypot()); + if !(lu.is_finite() && lv.is_finite() && lu > 1e-12 && lv > 1e-12) { + return None; + } + let deg = u.cross(v).atan2(u.dot(v)).abs().to_degrees(); + deg.is_finite().then_some(deg) +} + +/// Which anchors of `anchors` (a closed path when `closed`) the Smooth tool keeps as corners: those +/// where the path turns by more than [`SMOOTH_CORNER_DEG`] at the anchor itself, between the +/// direction it arrives along and the one it leaves along, and also over `reach` and over twice +/// `reach` on either side of it, between the direction from the point that far back along the path +/// to the anchor and the direction from the anchor to the point that far ahead. The points are taken +/// no farther than the ends of an open path, and no more than an eighth of the way round a closed +/// one. A jag or a bump smaller than the reach turns sharply at its anchor but not over twice the +/// reach, and an anchor in the middle of a tight curve turns over the reach but not at the anchor, so +/// neither is kept. An open path's first and last anchors are never corners. +fn smooth_corners(anchors: &[Anchor], closed: bool, reach: f64) -> Vec { + let n = anchors.len(); + let mut corners = vec![false; n]; + if n < 2 || (n < 3 && !closed) { + return corners; + } + // The distances on either side the turn is measured over: one reach and two, no more than an + // eighth of the way round a closed path. Over that much of a circle the path turns by 45°, so a + // loop shorter than the reach is not all corners, while a square's or a triangle's corners still are. + let mut spans = [reach, 2.0 * reach]; + if closed { + let mut total = 0.0; + for i in 0..n { + total += walk(anchors, closed, (i, true), [f64::INFINITY; 2], reach, 1).1; + } + if !(total.is_finite() && total > 0.0) { + return corners; + } + spans = spans.map(|r| r.min(total / 8.0)); + } + for (i, flag) in corners.iter_mut().enumerate() { + if !closed && (i == 0 || i == n - 1) { + continue; + } + let (prev, a, next) = (anchors[(i + n - 1) % n], anchors[i], anchors[(i + 1) % n]); + let arrive = [a.p - a.h_in, a.p - prev.h_out, a.p - prev.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default(); + let leave = [a.h_out - a.p, next.h_in - a.p, next.p - a.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default(); + if !turn_between(arrive, leave).is_some_and(|t| t > SMOOTH_CORNER_DEG) { + continue; + } + let [Some(back_near), Some(back_far)] = walk(anchors, closed, (i, false), spans, reach, MAX_CORNER_WALK).0 else { continue }; + let [Some(ahead_near), Some(ahead_far)] = walk(anchors, closed, (i, true), spans, reach, MAX_CORNER_WALK).0 else { continue }; + *flag = [(back_near, ahead_near, spans[0]), (back_far, ahead_far, spans[1])] + .into_iter() + .all(|(back, ahead, r)| r <= 0.0 || turn_between(a.p - back, ahead - a.p).is_some_and(|t| t > SMOOTH_CORNER_DEG)); + } + corners +} + +/// Most segments [`walk`] passes from an anchor before it stops, which bounds the work for each +/// anchor on a path of very many very short segments. +const MAX_CORNER_WALK: usize = 256; + +/// A walk along the path `anchors` from anchor `from.0`, ahead when `from.1` and back otherwise, +/// past no more than `segments` segments, measured along the lines between each curved segment's +/// points at [`corner_places`]. It gives the points at the distances `to` (in order, the nearer +/// first), an open path's end for one beyond the end, and `None` for one it did not reach within +/// `segments` segments, and the length it walked. Both points are `None` when it meets a length that +/// is not a number or is too large to add, and the length is then infinite. +fn walk(anchors: &[Anchor], closed: bool, from: (usize, bool), to: [f64; 2], reach: f64, segments: usize) -> ([Option; 2], f64) { + let (n, forward) = (anchors.len(), from.1); + let Some(mut here) = anchors.get(from.0).map(|a| a.p) else { return ([None; 2], 0.0) }; + let (mut at, mut gone) = (from.0, 0.0); + let mut found: [Option; 2] = [None; 2]; + let mut at_end = false; + 'segments: for _ in 0..segments { + let next = match (forward, closed) { + (true, _) if at + 1 < n => at + 1, + (true, true) => 0, + (false, _) if at > 0 => at - 1, + (false, true) => n - 1, + _ => { + at_end = true; + break; + } + }; + let (a, b) = (anchors[at], anchors[next]); + let c = if forward { CubicBez::new(a.p, a.h_out, b.h_in, b.p) } else { CubicBez::new(a.p, a.h_in, b.h_out, b.p) }; + let places = if c.p1 == c.p0 && c.p2 == c.p3 { vec![1.0] } else { corner_places(c, reach) }; + for u in places { + let q = if u >= 1.0 { c.p3 } else { c.eval(u) }; + let d = here.distance(q); + if !(d.is_finite() && (gone + d).is_finite()) { + return ([None; 2], f64::INFINITY); + } + for (slot, &dist) in found.iter_mut().zip(&to) { + if slot.is_none() && gone + d >= dist { + let t = if d > 0.0 { ((dist - gone) / d).clamp(0.0, 1.0) } else { 1.0 }; + *slot = Some(here.lerp(q, t)); + } + } + gone += d; + here = q; + if found.iter().all(Option::is_some) { + break 'segments; + } + } + at = next; + } + // An open path that ends after the last segment walked ends there too. + if !closed && at == if forward { n - 1 } else { 0 } { + at_end = true; + } + (found.map(|p| p.or(at_end.then_some(here))), gone) +} + +/// Whether `a` and `b` are the same anchors, to within a rounding error. +fn same_anchors(a: &[Anchor], b: &[Anchor]) -> bool { + let near = |p: Point, q: Point| p.distance(q) <= 1e-9; + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out)) +} + +/// [`refit_run`] of the open run `anchors` cut at its anchors marked in `corners`: each part between +/// two corners is smoothed and refitted on its own, so each corner keeps its place and the direction +/// the path arrives and leaves along. `free` applies to the run's first and last anchors only. +fn refit_split(anchors: &[Anchor], corners: &[bool], tol: f64, sigma: f64, free: [bool; 2]) -> Vec { + let last = anchors.len().saturating_sub(1); + let mut cuts: Vec = (1..last).filter(|&i| corners.get(i).copied().unwrap_or(false)).collect(); + if cuts.is_empty() { + return refit_run(anchors, tol, sigma, free); + } + cuts.insert(0, 0); + cuts.push(last); + let mut out: Vec = Vec::with_capacity(anchors.len()); + for w in cuts.windows(2) { + let (a, b) = (w[0], w[1]); + let part = refit_run(&anchors[a..=b], tol, sigma, [free[0] && a == 0, free[1] && b == last]); + match out.pop() { + // The corner ends one part and starts the next: it keeps its place, and takes the handle + // in from the part before and the handle out from the part after. + Some(end) => { + let mut rest = part.into_iter(); + if let Some(start) = rest.next() { + out.push(Anchor::with_handles(anchors[a].p, end.h_in, start.h_out)); + } + out.extend(rest); + } + None => out.extend(part), + } + } + out +} + /// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of /// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the -/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether +/// smoothing moved it. The tool cuts the path at its corners first ([`smooth_corners`]), so within a +/// part the refit keeps a corner only where the path turns back on itself. `free` says whether /// the start and the end of the stretch refitted are ends of an open path, whose direction then /// follows the smoothed path within the reach rather than the first or last segment. fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions { @@ -321,9 +549,31 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f 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().map(|r| start_nearest(r, start, sp)); + let corners = smooth_corners(&sp.anchors, true, radius); + let Some(c) = corners.iter().position(|c| *c) else { + // Everything is in range and nothing is a corner: 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().map(|r| start_nearest(r, start, sp)); + }; + // Cut the path open at a corner, which then ends the last part as well as starting the first. + let mut open = sp.anchors.clone(); + open.rotate_left(c); + let mut cut = corners; + cut.rotate_left(c); + open.push(open[0]); + cut.push(true); + let mut res = refit_split(&open, &cut, tol, sigma, [false; 2]); + if same_anchors(&res, &open) { + return None; + } + let l = res.pop()?; + if let Some(r0) = res.first_mut() { + *r0 = Anchor::with_handles(r0.p, l.h_in, r0.h_out); + } + // The path starts where it did when the refit keeps an anchor there, and otherwise at its + // point nearest there. + return Some(start_nearest(SubPath::new(res, true), start, sp)); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. let mut open = sp.clone(); @@ -333,7 +583,10 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f open.anchors.push(open.anchors[0]); m.push(false); open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false); + let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, radius, false); + if same_anchors(&res, &open.anchors) { + return None; + } let l = res.pop()?; if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; @@ -342,7 +595,8 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f 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)) + let res = smooth_open(&sp.anchors, &marked, near, tol, sigma, radius, true); + (!same_anchors(&res, &sp.anchors)).then(|| SubPath::new(res, false)) } /// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it @@ -376,8 +630,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez /// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next /// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first /// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends, -/// and those of the open run a closed path is cut into, keep their direction. -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec { +/// and those of the open run a closed path is cut into, keep their direction. Each run keeps its +/// corners ([`smooth_corners`] within `radius`, the tool's reach, measured within the run), and a run +/// whose refit changes nothing stays as it was. +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, radius: f64, open: bool) -> Vec { let n = anchors.len(); let mut runs = vec![]; let mut i = 0; @@ -418,7 +674,11 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); } let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()]; - let new = refit_run(&piece, tol, sigma, free); + let corners = smooth_corners(&piece, false, radius); + let new = refit_split(&piece, &corners, tol, sigma, free); + if same_anchors(&new, &piece) { + continue; + } out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); } out @@ -816,4 +1076,123 @@ mod tests { assert_eq!(drag_stretches(&one), vec![0]); assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12); } + + fn polyline(pts: &[(f64, f64)]) -> Vec { + pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect() + } + + /// Anchors on one point, anchors that are not numbers or are too far out to measure, and a + /// zigzag of 100,001 anchors 1 pt high give no corner and do not fail; a square's four anchors + /// are its corners, and an open path's ends never are. + #[test] + fn corners_are_found_only_where_the_path_turns_at_and_around_an_anchor() { + assert_eq!(smooth_corners(&polyline(&[(5.0, 5.0); 4]), false, 12.0), vec![false; 4]); + assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (f64::NAN, 1.0), (10.0, 0.0)]), false, 12.0), vec![false; 3]); + assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (1e308, 0.0), (1e308, 1e308), (-1e308, 0.0)]), true, 12.0), vec![false; 4]); + let zigzag: Vec<(f64, f64)> = (0..=100_000).map(|i| (f64::from(i), f64::from(i % 2))).collect(); + assert!(!smooth_corners(&polyline(&zigzag), false, 12.0).contains(&true)); + let square = polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + assert_eq!(smooth_corners(&square, true, 12.0), vec![true; 4]); + assert_eq!(smooth_corners(&square, false, 12.0), vec![false, true, true, false]); + assert_eq!(smooth_corners(&square, false, 0.0), vec![false, true, true, false], "with no reach the turn at the anchor decides"); + assert_eq!(smooth_corners(&square[..2], false, 12.0), vec![false; 2]); + } + + /// A 90° bump 400 pt high cut into 1 pt segments, between two long straight segments: with a reach + /// of 10 pt its top is a corner, and with a reach of 1,000 pt, where the walk passes more segments + /// than it measures before it gets that far, its top is not one, as the path turns by about 51° + /// over that reach. + #[test] + fn a_corner_is_not_kept_where_the_reach_is_not_measured() { + let side = 400.0 * std::f64::consts::SQRT_2; + let steps = side.ceil() as usize; + let mut pts = vec![(-3000.0, 0.0)]; + pts.extend((0..steps).map(|j| { + let f = j as f64 / steps as f64; + (-400.0 + 400.0 * f, 400.0 * f) + })); + pts.extend((0..=steps).map(|j| { + let f = j as f64 / steps as f64; + (400.0 * f, 400.0 - 400.0 * f) + })); + pts.push((3000.0, 0.0)); + let anchors = polyline(&pts); + let top = steps + 1; + assert_eq!(anchors[top].p, Point::new(0.0, 400.0)); + assert!(smooth_corners(&anchors, false, 10.0)[top]); + assert!(!smooth_corners(&anchors, false, 1000.0)[top]); + } + + /// A 60° corner between two curved segments 1,000 pt long is found at a reach of 2 pt as it is + /// when each segment is cut into a thousand pieces: the turns over the reach are measured along the + /// curve near the anchor, not along a chord much longer than the reach. + #[test] + fn a_corner_between_long_curves_is_measured_along_them() { + // The curves arrive along x and leave at 60° to it, each bending away from the other. + let d = kurbo::Vec2::from_angle(-60f64.to_radians()); + let side = kurbo::Vec2::new(-d.y, d.x) * 1000.0; + let v = Point::new(0.0, 0.0); + let a = Anchor::with_handles(Point::new(-1000.0, 1000.0), Point::new(-1000.0, 1000.0), Point::new(-300.0, 1000.0)); + let b = Anchor::with_handles(v, Point::new(-300.0, 0.0), v + d * 300.0); + let end = v + d * 1000.0 + side; + let c = Anchor::with_handles(end, v + d * 300.0 + side, end); + let long = [a, b, c]; + let fine: Vec = { + let sp = SubPath::new(long.to_vec(), false); + let mut pts = vec![]; + for i in 0..2 { + let s = sp.segment(i); + for j in 0..1000 { + let p = s.subsegment(f64::from(j) / 1000.0..f64::from(j + 1) / 1000.0); + if j == 0 && i == 0 { + pts.push(Anchor::with_handles(p.p0, p.p0, p.p1)); + } else if let Some(last) = pts.last_mut() { + *last = Anchor::with_handles(last.p, last.h_in, p.p1); + } + pts.push(Anchor::with_handles(p.p3, p.p2, p.p3)); + } + } + pts + }; + let at_v = |anchors: &[Anchor]| { + let corners = smooth_corners(anchors, false, 2.0); + anchors.iter().zip(corners).find(|(x, _)| x.p == v).map(|(_, c)| c) + }; + assert_eq!(at_v(&fine), Some(true), "cut finely, the anchor is a corner"); + assert_eq!(at_v(&long), Some(true)); + } + + /// A line that meets a curve leaving with no handle, turning by 58° at the anchor and by about + /// 57.53° over twice a reach of 0.02 pt: the anchor is a corner, measured either way along the path, + /// as the stretch measured finely near the anchor covers twice the reach along the curve. + #[test] + fn a_corner_next_to_a_curve_without_a_handle_is_kept() { + let at = 58f64.to_radians(); + let prev = Anchor::corner(Point::new(-100.0 * at.cos(), -100.0 * at.sin())); + let v = Point::new(0.0, 0.0); + let a = Anchor::with_handles(v, v, v); + let end = Point::new(600.0, 600.0); + let next = Anchor::with_handles(end, Point::new(200.0, 0.0), end); + assert_eq!(smooth_corners(&[prev, a, next], false, 0.02), vec![false, true, false]); + let back = |x: Anchor| Anchor::with_handles(x.p, x.h_out, x.h_in); + assert_eq!(smooth_corners(&[back(next), back(a), back(prev)], false, 0.02), vec![false, true, false]); + } + + /// A jagged closed path whose length is less than twice the reach: its jags are not corners, + /// while a triangle that small keeps its three. + #[test] + fn a_small_jagged_loop_has_no_corners() { + let ring: Vec<(f64, f64)> = (0..16) + .map(|i| { + let (a, r) = (f64::from(i) / 16.0 * std::f64::consts::TAU, if i % 2 == 0 { 3.0 } else { 2.0 }); + (r * a.cos(), r * a.sin()) + }) + .collect(); + let jags = polyline(&ring); + let sp = SubPath::new(jags.clone(), true); + assert!(subpath_len(&sp) < 24.0); + assert!(smooth_corners(&jags, true, 1e-6).iter().all(|c| *c), "each jag turns sharply at its anchor"); + assert_eq!(smooth_corners(&jags, true, 12.0), vec![false; 16]); + assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (6.0, 0.0), (3.0, 5.196)]), true, 12.0), vec![true; 3]); + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ecb5c3984..00b6a338f2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -808,11 +808,11 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result { let created = date_param(p, "created", C)?; let st = s.doc()?; // The row as the panel lists it now: keeping an edit in progress below changes the rows. - let chosen = match p.get("index") { - None | Some(Value::Null) => None, - Some(v) => { - let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row))); - Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone()) + let chosen = match super::index_opt(p, "index", C)? { + None => None, + Some(row) => { + let v = &p["index"]; + Some(st.history.state(&st.doc, row).ok_or_else(|| bad(C, format!("no history state {v}")))?.clone()) } }; settle_interaction(s)?; @@ -1276,23 +1276,28 @@ pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_ /// The artboards Paste in Place, in Front or in Back puts a copy on, in artboard order: `artboard` /// (the active one, #693), else `artboards`, else, while the Artboard tool is chosen with several /// artboards selected, those (noted in the journal as `artboards`, so a replay pastes on the same -/// ones); `None` when none is named. A named index that is not an artboard of the document, a -/// repeated one or an empty list is an error. +/// ones); `None` when none is named. A named value that is not a whole number (null included), a +/// named index that is not an artboard of the document, a repeated one or an empty list is an +/// error; every value is checked for its form before any is checked against the artboards. fn paste_targets(s: &mut Session, p: &Value, cmd: &str) -> Result>> { let n = s.doc()?.doc.artboards.len(); - let one = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < n); - let single = match p.get("artboard") { - Some(v) => Some(one(v).ok_or_else(|| bad(cmd, "`artboard` must be the index of an artboard of the document"))?), - None => None, - }; - let listed = match p.get("artboards") { + let single = p.get("artboard").map(|v| v.as_u64().map(index).ok_or_else(|| bad(cmd, "`artboard` must be a whole number"))).transpose()?; + let entries = match p.get("artboards") { Some(v) => { let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`artboards` must be a list of artboard indices"))?; - let mut all = list - .iter() - .map(one) - .collect::>>() - .ok_or_else(|| bad(cmd, "`artboards` names an artboard the document does not have"))?; + let all = list.iter().map(|i| i.as_u64().map(index)).collect::>>(); + Some(all.ok_or_else(|| bad(cmd, "`artboards` must contain whole numbers"))?) + } + None => None, + }; + if single.is_some_and(|i| i >= n) { + return Err(bad(cmd, "`artboard` must be the index of an artboard of the document")); + } + let listed = match entries { + Some(mut all) => { + if all.iter().any(|i| *i >= n) { + return Err(bad(cmd, "`artboards` names an artboard the document does not have")); + } all.sort_unstable(); if all.windows(2).any(|w| w[0] == w[1]) { return Err(bad(cmd, "`artboards` names an artboard twice")); diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 84fb121d0c..6995075d2f 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -66,7 +66,10 @@ use serde_json::{Value, json}; pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; -pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts}; +pub use encode::{ + ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, export_art_bounds, + note_texts, +}; 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}; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ca74243699..24a71cc883 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), 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), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. 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 (svgInteractivity; a URL is not checked). Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: of the layers the file draws, without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), 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), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the art it plots, without placed images, unpainted paths and art at 0% opacity), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. 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 (svgInteractivity; a URL is not checked). Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0fbffcea82..0bb18483cb 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -2,11 +2,13 @@ //! (unknown keys are ignored, so one params object can carry the options of several formats). use std::borrow::Cow; +use std::collections::HashSet; +use std::sync::Arc; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; -use vectorcraft_doc::{Artboard, Document}; +use vectorcraft_doc::{Artboard, Document, Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; @@ -419,13 +421,15 @@ pub(super) fn use_artboards(cmd: &str, p: &Value) -> Result> { /// With `useArtboards: false` in `p`: `doc` without its opacity-mask or symbol editing layer and /// with one artboard, the bounds of its visible art, and `p` without the artboard choice. `None` -/// when `useArtboards` is absent, null or true. A document with no visible art is refused. -pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result> { +/// when `useArtboards` is absent, null or true. The objects that `left_out` picks (layers or +/// objects the format leaves out, see [`hidden_nodes`]) are not measured, and a document with no +/// other visible art is refused. +pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value, left_out: impl Fn(&Node) -> bool) -> Result> { if use_artboards(cmd, p)? != Some(false) { return Ok(None); } let doc = &*doc.without_edit_modes(); - let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?; + let bounds = bounds_without(doc, &left_out).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?; let mut q = super::export::without_artboards(p); if let Some(o) = q.as_object_mut() { o.remove("useArtboards"); @@ -433,6 +437,107 @@ pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result Option { + let doc = &*doc.without_edit_modes(); + bounds_without(doc, &|n| format == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n)) +} + +/// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden +/// ([`hidden_nodes`]) and the ones in the art of the symbols that instances show taken out +/// ([`without_nodes`]: symbol art is measured with its hidden objects). An instance of a symbol +/// whose art is all picked or hidden is hidden too. The art of a dynamic symbol is measured whole: +/// an instance can paint its objects, and finds them by their places in the art. +fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option { + // The symbols instances show (the first of each name) that aren't dynamic. + let mut names = HashSet::new(); + let shown: Vec<(usize, &vectorcraft_doc::Symbol)> = + doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).filter(|(_, s)| !s.dynamic).collect(); + // The symbols whose art `left_out` leaves nothing of, those that only hold instances of such + // symbols included. + let mut empty: HashSet<&str> = HashSet::new(); + while let Some((_, s)) = shown.iter().find(|(_, s)| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty)) { + empty.insert(&s.name); + } + let left_out = + &|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n)); + let layers = hidden_nodes(&doc.layers, left_out); + let symbols: Vec<(usize, Arc)> = shown + .iter() + .filter(|(_, s)| !empty.contains(s.name.as_str())) + .filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art))) + .collect(); + if layers.is_none() && symbols.is_empty() { + return vectorcraft_render::encode::art_bounds(doc); + } + let mut d = doc.clone(); + if let Some(layers) = layers { + d.layers = layers; + } + for (i, art) in symbols { + if let Some(s) = d.symbols.get_mut(i) { + s.art = art; + } + } + vectorcraft_render::encode::art_bounds(&d) +} + +/// Whether `n` is hidden, picked by `left_out`, an instance of one of the `empty` symbols, or a +/// layer or a group holding only such objects (its clipping path among them when it clips). A +/// Shaper group, or a layer, group or instance with effects, fills or strokes of its own, is none +/// of these unless it is hidden or picked. +fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>) -> bool { + !n.visible + || left_out(n) + || !has_effects(n) + && n.extras().shaper.is_none() + && match &n.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().all(|c| all_left_out(c, left_out, empty)), + NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()), + _ => false, + } +} + +/// Whether `n` has a visible effect, fill or stroke of its own, which the objects it holds can +/// take part in. +fn has_effects(n: &Node) -> bool { + n.appearance.effects.iter().any(|e| e.visible) || n.appearance.items.iter().any(|i| i.visible()) +} + +/// `n` without the objects that `left_out` picks in it, when it is a layer or a group, and in the +/// layers and groups in it at any depth; `None` when it picks none of them. A clipping layer or +/// group keeps its clipping path, its first object, and a Shaper group and a layer or group with +/// effects, fills or strokes of its own keep all they hold. +fn without_nodes(n: &Arc, left_out: &dyn Fn(&Node) -> bool) -> Option> { + let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None }; + if has_effects(n) || n.extras().shaper.is_some() { + return None; + } + let mut changed = false; + let kept: Vec> = children + .iter() + .enumerate() + .filter_map(|(i, c)| { + if left_out(c) && !(*clip && i == 0) { + changed = true; + return None; + } + Some(without_nodes(c, left_out).inspect(|_| changed = true).unwrap_or_else(|| c.clone())) + }) + .collect(); + changed.then(|| { + let mut new = (**n).clone(); + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind { + *children = kept; + } + Arc::new(new) + }) +} + /// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when /// absent; an error when it is not four finite numbers with a positive size, when the format isn't /// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would @@ -481,6 +586,35 @@ pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> D d } +/// `nodes` with the visible objects that `left_out` picks hidden, in layers, sublayers and groups +/// that don't clip, at any depth (what an object it picks holds is not looked at); `None` when it +/// picks none of them. Clipping layers are looked in too, as their sublayers can be picked. +pub(super) fn hidden_nodes(nodes: &[Arc], left_out: &dyn Fn(&Node) -> bool) -> Option>> { + let mut out: Option>> = None; + for (i, l) in nodes.iter().enumerate() { + let new = if left_out(l) { + l.visible.then(|| { + let mut n = (**l).clone(); + n.visible = false; + Arc::new(n) + }) + } else { + let (NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false }) = &l.kind else { continue }; + hidden_nodes(children, left_out).map(|c| { + let mut n = (**l).clone(); + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut n.kind { + *children = c; + } + Arc::new(n) + }) + }; + if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| nodes.to_vec()).get_mut(i)?, new))) { + *slot.0 = slot.1; + } + } + out +} + fn boards(r: std::result::Result) -> Result { r.map_err(|e| bad(C, e)) } @@ -636,8 +770,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { { return Ok(enc); } + let left_out = |n: &Node| f.id == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n); if use_artboards == Some(false) - && let Some((art, q)) = art_page(C, doc, p)? + && let Some((art, q)) = art_page(C, doc, p, left_out)? { return encode_files(&art, f, &q); } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index bb9dc3711a..1a3c4c6e2c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -550,9 +550,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // Optical Margin Alignment setting and writing direction. When that changes the direction of a // frame of a restored document, the story flows through that frame's thread again. Threaded // frames keep their size: Auto Size is off for them. The story is laid out in the document's - // own composite fonts. + // own composite fonts, and type naming one the document does not define flows as in a missing + // font, also when the document defines none. let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() }; - let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts); + let lent = vectorcraft_text::FontDb::global().lend_document_composite_fonts(&fonts); if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index a891a81ff2..da6da069e9 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; 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", + "{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 the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), 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 ), @@ -289,7 +289,7 @@ pub const EDITING_NEEDS_THE_ARTBOARDS: &str = /// Editing on, the notes end with [`EDITING_NEEDS_THE_ARTBOARDS`]. `None` when `p` asks for the /// artboards. pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result, Vec)>> { - let Some((art, mut q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let Some((art, mut q)) = art_page(cmd, doc, p)? else { return Ok(None) }; let left_out = settings(cmd, &q)?.preserve_editing; if left_out && let Some(o) = q.as_object_mut() { o.insert("preserveEditing".into(), json!(false)); @@ -301,6 +301,18 @@ pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result Result> { + if super::encode::use_artboards(cmd, p)? != Some(false) { + return Ok(None); + } + let set = settings(cmd, p)?; + let keeps = set.include_non_printing || set.create_layers; + super::encode::art_page(cmd, doc, p, |l| !keeps && matches!(l.kind, NodeKind::Layer { printable: false, .. })) +} + /// The warning for a PDF/X file that leaves out the document's bleed /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; @@ -477,29 +489,7 @@ fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result]) -> Option>> { - let mut out: Option>> = None; - for (i, l) in layers.iter().enumerate() { - let NodeKind::Layer { printable, children, .. } = &l.kind else { continue }; - let new = if !printable { - l.visible.then(|| { - let mut n = (**l).clone(); - n.visible = false; - Arc::new(n) - }) - } else { - without_non_printing(children).map(|c| { - let mut n = (**l).clone(); - if let NodeKind::Layer { children, .. } = &mut n.kind { - *children = c; - } - Arc::new(n) - }) - }; - if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) { - *slot.0 = slot.1; - } - } - out + super::encode::hidden_nodes(layers, &|l| matches!(l.kind, NodeKind::Layer { printable: false, .. })) } fn export_pdf(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index af9ed8e58a..e4a72ffc35 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -757,8 +757,9 @@ pub const AI_EDITING_NEEDS_THE_ARTBOARDS: &str = pub const ART_PAGE_NEEDS_PDF: &str = "pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false"; /// With `useArtboards: false` in `p`: the PDF-compatible `.ai` file of one page, the bounds of -/// `doc`'s visible art ([`super::encode::art_page`]). It carries neither the native document nor -/// the editing copy for other apps, and its notes end with [`AI_EDITING_NEEDS_THE_ARTBOARDS`]. +/// `doc`'s visible art on the layers its pages draw ([`super::pdf::art_page`]). It carries neither +/// the native document nor the editing copy for other apps, and its notes end with +/// [`AI_EDITING_NEEDS_THE_ARTBOARDS`]. /// `pdfCompatible: false` is refused. `None` when `p` asks for the artboards or for a legacy /// version 7 `.ai` file. pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { @@ -768,7 +769,7 @@ pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Valu if !bool_or(p, "pdfCompatible", true) { return Err(bad(cmd, ART_PAGE_NEEDS_PDF)); } - let Some((art, q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let Some((art, q)) = super::pdf::art_page(cmd, doc, &ai_pdf_params(p, false))? else { return Ok(None) }; let mut enc = encode_ai_pages(cmd, f, &art, &q, false)?; enc.setting_notes.push(AI_EDITING_NEEDS_THE_ARTBOARDS.to_string()); Ok(Some(enc)) @@ -784,9 +785,10 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res encode_ai_pages(cmd, f, doc, p, true) } -/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and -/// the editing copy for other apps when `editing` is true, and neither otherwise. -fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result { +/// `p` with the PDF options a `.ai` file is written with: Preserve Editing when `editing` is true, +/// layers unless a standard decides, long pages with a `UserUnit`, and `compress` as +/// `compression.compressText`. +fn ai_pdf_params(p: &Value, editing: bool) -> Value { let mut params = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = params.as_object_mut() { o.insert("preserveEditing".into(), json!(editing)); @@ -805,6 +807,13 @@ fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bo } } } + params +} + +/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and +/// the editing copy for other apps when `editing` is true, and neither otherwise. +fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result { + let params = ai_pdf_params(p, editing); let pdf_compatible = bool_or(¶ms, "pdfCompatible", true); let f = &if pdf_compatible { *f } else { without_pdf(f) }; let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index f4767fd81d..a7fe16690e 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -170,6 +170,113 @@ fn a_plot_reads_back_as_its_art() { assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}"); } +/// A plot of the art's bounds is measured on the art it plots: art on a non-printing layer, on a +/// layer at 0% opacity or at 0% opacity in a group, a placed image and a path that paints nothing +/// don't move its origin, a clipping layer is measured as clipped, and a document whose only +/// visible art is such art is refused. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_the_layers_it_does_not_plot() { + let mut s = two_boards(); + let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v)); + let alone = plot(&mut s).unwrap(); + let printing = s.doc().unwrap().doc.layers[0].id; + for props in [json!({"printable": false}), json!({})] { + let layer = s.execute("layer.new", &json!({})).unwrap()["id"].clone(); + if props.as_object().unwrap().is_empty() { + s.execute("transparency.set", &json!({"ids": [layer], "opacity": 0})).unwrap(); + } else { + let mut p = props.clone(); + p["id"] = layer.clone(); + s.execute("layer.setProps", &p).unwrap(); + } + s.execute("shape.rectangle", &json!({"x": -300, "y": 500, "width": 20, "height": 20})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "{props}"); + } + // An object at 0% opacity in a group, on a layer that plots. + s.execute("layer.new", &json!({})).unwrap(); + let far = s.execute("shape.rectangle", &json!({"x": -500, "y": 700, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("object.group", &json!({"ids": [far]})).unwrap(); + s.execute("transparency.set", &json!({"ids": [far], "opacity": 0})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "0% opacity in a group"); + // A placed image and a path whose fill and stroke paint nothing. + let mut png = vec![]; + image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "a placed image"); + let bare = s.execute("shape.rectangle", &json!({"x": 500, "y": -400, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [bare], "none": true})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "a path that paints nothing"); + + // A clipping layer's clipping path, which loses its paint, still clips the art measured: a + // large square clipped to the first square draws nothing more. + let clipping = s.execute("layer.new", &json!({})).unwrap()["id"].clone(); + s.execute("shape.rectangle", &json!({"x": -300, "y": -300, "width": 800, "height": 800})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("layer.clippingMask.toggle", &json!({"id": clipping})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "a clipping layer"); + + s.execute("layer.setProps", &json!({"id": clipping, "visible": false})).unwrap(); + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); +} + +/// A placed image in a symbol's art doesn't move a plot of the art's bounds either, and a +/// document whose only other visible art is an instance of that symbol is refused. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() { + let mut s = two_boards(); + let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v)); + let alone = plot(&mut s).unwrap(); + let printing = s.doc().unwrap().doc.layers[0].id; + s.execute("layer.new", &json!({})).unwrap(); + let mut png = vec![]; + image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Far"})).unwrap(); + assert!(s.doc().unwrap().doc.symbols.iter().any(|sym| sym.name == "Far")); + assert_eq!(plot(&mut s).unwrap(), alone, "an image in symbol art"); + + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + + // A symbol of a square and an image is measured by its square. + let bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap(); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone(); + let before = bounds(&s); + let image = s.execute("file.place", &json!({"name": "near.png", "dataBase64": b64(&png), "at": [400, 600]})).unwrap()["ids"][0].clone(); + s.execute("symbol.new", &json!({"name": "Both", "ids": [square, image]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.symbols.len(), 2); + let after = bounds(&s); + for (a, b) in [(before.x0, after.x0), (before.y0, after.y0), (before.x1, after.x1), (before.y1, after.y1)] { + assert!((a - b).abs() < 1e-6, "{before:?} {after:?}"); + } +} + +/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left +/// out when it would take more cells than the effect makes. +#[test] +fn a_plot_of_the_arts_bounds_measures_images_by_their_halftone_dots() { + let mut s = two_boards(); + let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v)); + let printing = s.doc().unwrap().doc.layers[0].id; + s.execute("layer.new", &json!({})).unwrap(); + let mut png = vec![]; + image::DynamicImage::new_rgb8(100, 100).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "dots.png", "dataBase64": b64(&png), "at": [300, 300]})).unwrap(); + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + + // At 300 lines per inch a 100 pt image takes more cells than the effect makes. + s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 300}})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + + s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 20}})).unwrap(); + let out = String::from_utf8(plot(&mut s).unwrap()).unwrap(); + assert!(out.contains("PD"), "{out}"); +} + #[test] fn export_options_are_checked() { let mut s = two_boards(); diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index f57e02fc2d..f5b1b8e34d 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -103,6 +103,48 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() { } } +/// A page of the art's bounds is measured on the layers the PDF draws: a non-printing layer that +/// the PDF leaves out doesn't make it larger, unless `includeNonPrinting` or `createLayers` keeps +/// the layer. A `.ai` file of the art's bounds, which keeps layers by default, measures it unless +/// `createLayers` is off. A document whose only visible art doesn't print is refused. +#[test] +fn a_page_of_the_arts_bounds_leaves_out_non_printing_layers() { + let mut s = session(1); + let printing = s.doc().unwrap().doc.layers[0].id; + let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone(); + s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap(); + let size = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().artboards[0].rect.size(); + let printed = size(&b64(&export(&mut s, json!({"useArtboards": false})))); + assert!(printed.width < 50.0 && printed.height < 40.0, "{printed:?}"); + let through_export = s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap(); + assert_eq!(size(&b64(&through_export)), printed); + let ai = |s: &mut Session, extra: Value| { + let mut p = json!({"format": "ai", "useArtboards": false}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + size(&b64(&s.execute("document.export", &p).unwrap_or_else(|e| panic!("{p}: {e}")))) + }; + assert_eq!(ai(&mut s, json!({"createLayers": false})), printed); + let kept = [ + size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))), + size(&b64(&export(&mut s, json!({"useArtboards": false, "createLayers": true})))), + ai(&mut s, json!({})), + ]; + for all in kept { + assert!(all.width > 200.0 && all.height > 200.0, "{all:?}"); + } + + // Only the non-printing layer shows: the PDF would draw nothing. + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + for p in [json!({"useArtboards": false}), json!({"useArtboards": false, "includeNonPrinting": false})] { + let e = s.execute("document.exportPdf", &p).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{p}: {e}"); + } + let e = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "createLayers": false})).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + assert!(size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))).width < 25.0); +} + /// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a /// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5 /// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index ef8425b362..b159fa7361 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -797,22 +797,47 @@ fn place_of(d: &Document, roots: &[NodeId]) -> Option { } /// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer -/// that holds it, in order. Other objects hold none. +/// that holds it, in order. Other objects hold none. Each root, and each child of it and of its +/// sublayers, is looked at once. fn held_guides(d: &Document, roots: impl IntoIterator) -> Vec<(NodeId, Node)> { let mut out = vec![]; for id in roots { let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue }; - if n.holds_ruler_guides() { - n.walk(&mut |c| { - if c.is_ruler_guide() { - out.push((parent, c.clone())); - } - }); + guide_seen(n); + if n.is_layer() { + guides_of(n, parent, &mut out); } } out } +/// Push each ruler guide of `layer` and of its sublayers onto `out` in paint order, with `parent`. +fn guides_of(layer: &Node, parent: NodeId, out: &mut Vec<(NodeId, Node)>) { + for c in layer.children().into_iter().flatten() { + guide_seen(c); + if c.is_ruler_guide() { + out.push((parent, Node::clone(c))); + } else if c.is_layer() { + guides_of(c, parent, out); + } + } +} + +#[cfg(test)] +thread_local! { + /// The nodes that [`held_guides`] has looked at on this thread, in order. + static GUIDES_SEEN: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Note that [`held_guides`] looked at `n`. +#[cfg(test)] +fn guide_seen(n: &Node) { + GUIDES_SEEN.with(|s| s.borrow_mut().push(n.id)); +} + +#[cfg(not(test))] +fn guide_seen(_: &Node) {} + /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); @@ -922,6 +947,53 @@ mod tests { assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); } + /// The guides of a sublayer and of its sublayers are gathered in paint order with the layer + /// that holds it, looking at the sublayer and each child of it and of its sublayers once. + /// Other roots give none. + #[test] + fn held_guides_look_at_each_node_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sublayer = + |s: &mut Session, parent: NodeId| NodeId(s.execute("layer.newSublayer", &json!({"parent": parent.0})).unwrap()["id"].as_u64().unwrap()); + let rect = |s: &mut Session, on: NodeId| { + s.execute("layer.setCurrent", &json!({"id": on.0})).unwrap(); + NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()) + }; + let sub = sublayer(&mut s, layer); + let deep = sublayer(&mut s, sub); + let empty = sublayer(&mut s, layer); + let (a, b, c) = (rect(&mut s, sub), rect(&mut s, deep), rect(&mut s, empty)); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let mut guide = |on: NodeId, pos: f64| { + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + d.insert(Some(on), usize::MAX, g).unwrap() + }; + let (g1, g2, g3) = (guide(sub, 1.0), guide(deep, 2.0), guide(sub, 3.0)); + let doc = &s.doc().unwrap().doc; + let ids = |n: &Node| { + let mut v = vec![]; + n.walk(&mut |c| v.push(c.id)); + v + }; + assert_eq!(ids(doc.node(sub).unwrap()), vec![sub, deep, b, g2, a, g1, g3]); + let run = |roots: &[NodeId]| { + GUIDES_SEEN.with(|v| v.borrow_mut().clear()); + let got: Vec<_> = held_guides(doc, roots.iter().copied()).into_iter().map(|(p, g)| (p, g.id)).collect(); + (got, GUIDES_SEEN.with(|v| v.take())) + }; + // Every node of the sublayer is looked at once, and the guides keep their paint order. + assert_eq!(run(&[sub]), (vec![(layer, g2), (layer, g1), (layer, g3)], vec![sub, deep, b, g2, a, g1, g3])); + // A sublayer that holds none gives none; a path holds none; a missing root and a top-level + // layer, which has no parent, are skipped. + assert_eq!(run(&[empty]), (vec![], vec![empty, c])); + assert_eq!(run(&[a]), (vec![], vec![a])); + assert_eq!(run(&[NodeId(u64::MAX), layer]), (vec![], vec![])); + // A deeper sublayer gives its guides to the sublayer that holds it. + assert_eq!(run(&[deep, deep]).0, vec![(sub, g2), (sub, g2)]); + } + /// In a file without transparency with Clip Complex Regions on, art under a group's opaque /// image within one of its pixels joins the group, also where a long image is made at a lower /// resolution and its pixels are wider than at the preset's. diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index f454d7133b..5cbdd8f291 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -6,20 +6,34 @@ //! (), in our words: //! //! - With one object selected, Start Global Edit finds the objects that repeat its shape: the -//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance -//! (the same fill and stroke too) and Size (the same width and height too). The search covers -//! every artboard and the canvas around them by default; it can be limited to a range of -//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type, -//! masks, linked files and selections of more than one object are not supported. +//! same outline, wherever it lies, at whatever size and at whatever angle. Two options narrow +//! the match: Appearance (the same fill and stroke too) and Size (the same width and height +//! too). The search covers every artboard and the canvas around them by default; it can be +//! limited to a range of artboards (`1, 3-5`) and can leave out the objects that lie on no +//! artboard. Images, type, masks, linked files and selections of more than one object are not +//! supported. //! - The help page "Edit groups with similar objects" //! (): //! with a group selected, Start Global Edit finds the groups like it, and an edit made to an //! object inside one of them is made to the matching object in each of the others. Here a group //! is like another when it holds as many objects, in the same order, each like the one at its //! place (paths, compound paths, symbol instances and groups of them, clipping paths included), -//! and they lie the same way relative to each other: the group as a whole is moved or scaled. -//! With Appearance on, each of those objects must have the same fill and stroke too; with Size, -//! the groups the same width and height. +//! and they lie the same way relative to each other: the group as a whole is moved, scaled or +//! rotated. With Appearance on, each of those objects must have the same fill and stroke too; +//! with Size, the groups the same width and height. +//! - The how-to "Design a modern geometric logo" +//! () makes rotated +//! copies of a group of shapes around a point, then starts Global Edit on one object and edits +//! every copy of it at once. Here a rotated copy is found as a moved or scaled one is, and an +//! edit reaches it turned by its own angle: an anchor moved to the right on the selected object +//! moves downward on a copy turned a quarter turn clockwise. The rules below for rotated copies +//! are VectorCraft's own. The angle is the one that lays each anchor and handle of the selected +//! object on the copy's of the same order, so a square whose anchors were turned a quarter turn +//! is a copy turned a quarter turn. A copy is the same shape when, laid so, every anchor and +//! handle lies within a millionth of the larger side of the copy's box of its own ([`SAME`]). +//! A reflected copy is another shape. With Size, a rotated copy has the same size when it is the +//! selected object rotated without scaling: its width and height are compared as they were +//! before the rotation. //! - Selecting one of the repeated objects with the Selection tool highlights the similar objects //! at once, before Global Edit starts. Here they are the objects Start Global Edit would take //! with the current Global Edit Options ([`similar`]). @@ -29,15 +43,16 @@ //! //! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the //! object that was selected, so that the Transform panel and the bounding box measure that object -//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps -//! the first object's outline onto its own. After every edit ([`propagate`], run inside -//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit -//! changed is the model: what changed about it is given to every other member, and nothing else, -//! so members found by shape alone keep their own fills and strokes where the edit didn't touch -//! them: +//! and not the whole set. Each member keeps the transform (a move, a uniform scale and, for a +//! rotated copy, a turn) that maps the first object's outline onto its own. After every edit +//! ([`propagate`], run inside [`Session::edit`], so the whole change is one undo step), the first +//! selected member the edit changed is the model: what changed about it is given to every other +//! member, and nothing else, so members found by shape alone keep their own fills and strokes +//! where the edit didn't touch them: //! //! - its outline (and anchors), placed through that transform, so in proportion to each member's -//! size. Only geometry is placed so: no stroke weight is scaled to a member's size; +//! size and turned by its angle. Only geometry is placed so: no stroke weight is scaled to a +//! member's size; //! - its opacity, blending, knockout and the object's effects, each when it changed; //! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending, //! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the @@ -74,12 +89,12 @@ //! and later edits leave it as it is. //! The object the edit started from can't be left out (a Shift-click on it selects it or //! deselects it as before), Global Edit ends when fewer than two members are left, and leaving -//! one out is not an undo step. Rotated copies are not covered. +//! one out is not an undo step. use serde::Serialize; use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer}; -use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape, Vec2}; use super::wand::{fill_of, stroke_of}; use super::{CommandSpec, Result, Session, bad, bool_or, checked_id, cmd, has_doc}; @@ -98,7 +113,7 @@ pub fn specs() -> Vec { "Start Global Edit", ["Select"], None, - "{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object (a path, compound path, symbol instance or group of them), with the objects of the same shape (for a group: the groups holding objects like its own, at the same places) (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard), at most 10000 → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}", + "{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object (a path, compound path, symbol instance or group of them), with the objects of the same shape, moved, scaled or rotated copies of it and not reflected ones (for a group: the groups holding objects like its own, at the same places) (and, with `appearance`, the same fill and stroke; with `size`, the same width and height, for a rotated copy before its rotation) on the artboards given (and, with `includeCanvas`, off every artboard), at most 10000 → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}", can_toggle, toggle ), @@ -181,7 +196,8 @@ pub type Outlines = Vec<(BezPath, [u8; 3])>; #[derive(Clone, Debug, PartialEq)] pub struct GlobalEdit { /// The objects edited together, the one selected at the start first, each with the transform - /// (a move and a uniform scale) that maps that first object's outline onto its own. + /// (a move, a uniform scale and, for a turned copy, a turn) that maps that first object's + /// outline onto its own. pub members: Vec<(NodeId, Affine)>, /// [`highlights`] as of a document revision: the canvas asks every frame. outlines: std::cell::RefCell>, @@ -257,14 +273,60 @@ impl Form { } } - fn same(&self, o: &Form) -> bool { - self.tag == o.tag - && self.symbols == o.symbols - && self.points.len() == o.points.len() - && self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6) + /// The transform that lays this form's object on `o`'s, point for point in order, and whether + /// it turns the object: a move and a uniform scale when the two lie the same way, otherwise a + /// turn too. `None` when `o` is another shape, a reflected copy included. + fn onto(&self, o: &Form) -> Option<(Affine, bool)> { + if self.tag != o.tag || self.symbols != o.symbols || self.points.len() != o.points.len() { + return None; + } + let near = |xf: Affine| self.points.iter().zip(&o.points).all(|(a, b)| (xf * *a).distance(*b) < SAME); + let back = self.frame.inverse(); + if near(Affine::IDENTITY) { + return Some((o.frame * back, false)); + } + let turn = self.turn_onto(o)?; + near(turn).then(|| (o.frame * turn * back, true)) + } + + /// The turn, uniform scale and move (both forms in their unit boxes) that best lays this + /// form's points on `o`'s in order, by least squares. A turn within [`SQUARE`] of a quarter + /// turn is that quarter turn exactly. + fn turn_onto(&self, o: &Form) -> Option { + let n = self.points.len() as f64; + let middle = |f: &Form| f.points.iter().fold(Vec2::ZERO, |s, p| s + p.to_vec2()) / n; + let (from, to) = (middle(self), middle(o)); + // With the points as complex numbers about their middles, the fit is `Σ conj(p) q / Σ |p|²`. + let (mut re, mut im, mut spread) = (0.0, 0.0, 0.0); + for (a, b) in self.points.iter().zip(&o.points) { + let (p, q) = (a.to_vec2() - from, b.to_vec2() - to); + re += p.dot(q); + im += p.cross(q); + spread += p.hypot2(); + } + let len = re.hypot(im); + let scale = len / spread; + if !(scale.is_finite() && scale > 0.0) { + return None; + } + let (mut cos, mut sin) = (re / len, im / len); + if cos.abs() < SQUARE { + (cos, sin) = (0.0, sin.signum()); + } else if sin.abs() < SQUARE { + (cos, sin) = (cos.signum(), 0.0); + } + let (a, b) = (scale * cos, scale * sin); + Some(Affine::translate(to) * Affine::new([a, b, -b, a, 0.0, 0.0]) * Affine::translate(-from)) } } +/// How far apart two forms' points may lie, each form in its unit box, for the forms to be the +/// same. For a turned copy this bounds the angle too: a copy turned and then changed by more than +/// a millionth of its larger side is another shape. +const SAME: f64 = 1e-6; +/// A turn whose sine or cosine is below this is taken as a whole number of quarter turns. +const SQUARE: f64 = 1e-9; + /// Global Edit supports paths, compound paths, symbol instances and groups of them; not images, /// type, linked files, objects with an opacity mask, clipping paths or guides (the help page lists /// images, text, masks and links as unsupported). @@ -395,8 +457,16 @@ fn matches(s: &Session, o: &Options) -> Result> { } boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b))) }; + // A copy that lies the same way has the size of the selected object when their boxes are as + // wide and as high; a turned copy, when the transform that lays the object on it doesn't scale. + let same_size = |n: &Node, xf: Affine, turned: bool| { + if turned { + close(xf.determinant().abs().sqrt(), 1.0) + } else { + matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2)) + } + }; let mut out = vec![(*id, Affine::IDENTITY)]; - let back = form.frame.inverse(); super::select::walk_selectable(d, st.isolation, |n| { if n.id == *id || out.len() >= MAX_MEMBERS { return false; @@ -413,12 +483,12 @@ fn matches(s: &Session, o: &Options) -> Result> { }; // The object count first: it turns most objects down before their form is made. if n.children().map(Vec::len) == count - && let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n)) - && (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2))) + && let Some((xf, turned)) = Form::of(n).and_then(|f| form.onto(&f)).filter(|_| supported(n)) + && (!o.size || same_size(n, xf, turned)) && (!o.appearance || same_look(key, n)) && in_scope(n) { - out.push((n.id, f.frame * back)); + out.push((n.id, xf)); // A group taken is taken whole, as compound paths, symbol instances and the others are. return false; } @@ -464,7 +534,7 @@ fn toggle(s: &mut Session, p: &Value) -> Result { let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?; let members = matches(s, &o)?; if members.len() < 2 { - return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again")); + return Err(bad(C, "no similar objects found (reflected copies are not matched); change the Global Edit Options and try again")); } let ids: Vec = members.iter().map(|(id, _)| id.0).collect(); let st = s.doc_mut()?; @@ -1027,6 +1097,38 @@ mod tests { assert!(taken > 100 && checked - taken > 100 && asked > 200, "{taken} of {checked}, {asked} selected"); } + #[test] + fn a_form_is_laid_on_a_turned_copy_and_on_no_reflected_or_unmeasurable_one() { + let mut d = Document::new(800.0, 600.0); + let mut path = |points: &[(f64, f64)]| { + let anchors = points.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(); + Node::path(d.alloc_id(), PathData::new(vec![SubPath::new(anchors, true)]), Appearance::default()) + }; + let form = Form::of(&path(&[(0.0, 0.0), (30.0, 0.0), (30.0, 70.0)])).unwrap(); + let mut onto = |points: &[(f64, f64)]| Form::of(&path(points)).and_then(|f| form.onto(&f)); + let near = |xf: Affine, want: [f64; 6]| xf.as_coeffs().iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9); + // The same way at twice the size: a move and a scale, no turn. + let (xf, turned) = onto(&[(100.0, 0.0), (160.0, 0.0), (160.0, 140.0)]).unwrap(); + assert!(!turned && near(xf, [2.0, 0.0, 0.0, 2.0, 100.0, 0.0]), "{xf:?}"); + // A quarter turn, (x, y) → (−y, x), moved by (100, 0); and a turn by 30° at half the size. + let (xf, turned) = onto(&[(100.0, 0.0), (100.0, 30.0), (30.0, 30.0)]).unwrap(); + assert!(turned && near(xf, [0.0, 1.0, -1.0, 0.0, 100.0, 0.0]), "{xf:?}"); + let by = Affine::rotate(30f64.to_radians()) * Affine::scale(0.5); + let at = |x: f64, y: f64| { + let p = by * Point::new(x, y); + (p.x, p.y) + }; + let (xf, turned) = onto(&[at(0.0, 0.0), at(30.0, 0.0), at(30.0, 70.0)]).unwrap(); + assert!(turned && near(xf, by.as_coeffs()), "{xf:?}"); + // A reflected copy, a copy with an anchor a thousandth of its size away, and points that + // are not numbers or not finite. + assert!(onto(&[(0.0, 0.0), (-30.0, 0.0), (-30.0, 70.0)]).is_none()); + assert!(onto(&[at(0.0, 0.0), at(30.0, 0.0), at(30.0, 70.07)]).is_none()); + assert!(onto(&[(f64::NAN, 0.0), (30.0, 0.0), (30.0, 70.0)]).is_none()); + assert!(onto(&[(0.0, 0.0), (f64::INFINITY, 0.0), (30.0, 70.0)]).is_none()); + assert!(onto(&[(0.0, 0.0), (30.0, 0.0)]).is_none(), "another count of anchors"); + } + #[test] fn support_of_empty_degenerate_and_deep_objects_is_decided_as_building_the_form_decides() { let mut d = Document::new(800.0, 600.0); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index b5acb1ba66..5f6d602ad5 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -631,19 +631,33 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], 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. +/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). A `change` that +/// sets the fill of legend labels marks it as set on them, so a black one stays black on dark pie wedges. Returns whether +/// the kept style or that mark 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); + // The kept fill can already be the one `change` sets (black, the default): `change` sets the fill when it gives one + // of two different fills something else. + let sets_fill = role == Role::Legend + && [Paint::None, Paint::solid(Color::BLACK)].into_iter().any(|fill| { + let mut probe = CharStyle { fill: fill.clone(), ..style.clone() }; + change(&mut probe); + probe.fill != fill + }); change(&mut style); + let styles = &mut spec.label_styles; 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, + Role::Value => &mut styles.values, + Role::Category => &mut styles.categories, + Role::Legend => &mut styles.legend, }; - let changed = slot.as_ref() != Some(&style); + let mut changed = slot.as_ref() != Some(&style); *slot = Some(style); + if sets_fill && !styles.legend_fill_set { + styles.legend_fill_set = true; + changed = true; + } changed } @@ -872,12 +886,9 @@ fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Ve } /// 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? +/// Legends in Wedges whose legend labels keep no fill of their own ([`GraphSpec::picks_legend_fill`])? 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)) + g.picks_legend_fill() } /// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index f56ec5a5dc..04eff93a6c 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -6361,6 +6361,93 @@ fn pie_names_and_wedge_labels_keep_their_style() { assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge"); } +/// On a pie with Legends in Wedges, a black fill given to the white legend label on the dark wedge is kept for every +/// legend label: the graph draws it black on the dark wedge again, also after a native save and open. +#[test] +fn a_black_fill_set_on_a_wedge_legend_label_stays_black_on_dark_wedges() { + let cases = [("paint.setFill", json!({"color": "#000000"})), ("text.setStyle", json!({"fill": "#000000"})), ("edit.colors.invert", json!({}))]; + every_case(cases, |(cmd, params)| { + 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(); + let legend_fill = |s: &Session, text: &str| { + let NodeKind::Text(t) = label_node(s, id, "Legend", text).kind else { panic!("{text} is not type") }; + t.runs[0].style.fill.color().map(|c| c.to_hex()).unwrap_or_default() + }; + assert_eq!(legend_fill(&s, "a"), "#ffffff", "white on the black wedge"); + let mut p = params.clone(); + p["ids"] = json!([text_id(&s, id, "Legend", "a").0]); + s.execute(cmd, &p).unwrap(); + assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!spec(&s, id).label_styles.legend_fill_set, "{cmd}: undone"); + assert_eq!(legend_fill(&s, "a"), "#ffffff", "{cmd}: white again after the undo"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}: redone"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap(); + assert_eq!((legend_fill(&s, "a"), legend_fill(&s, "b")), ("#000000".into(), "#000000".into()), "{cmd}: black on both wedges"); + let dir = vectorcraft_testkit::TempDir::new("graph-wedge-legend"); + let path = dir.join("pie.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}: kept through save and open"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 1]]})).unwrap(); + assert_eq!(legend_fill(&s, "a"), "#000000", "{cmd}: still black after open"); + }); +} + +/// A size given to the legend labels of a pie with Legends in Wedges keeps the default black fill with it. A color mode +/// change leaves that fill black, so the legend label on the dark wedge is still drawn white. A legend fill that is not +/// black, in a file saved without the mark, is marked as set by the change. +#[test] +fn a_color_mode_change_keeps_the_default_wedge_legend_fill() { + 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("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 11})).unwrap(); + s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); + let styles = spec(&s, id).label_styles; + assert!(!styles.legend_fill_set && !styles.legend_fill_is_own(), "{styles:?}"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap(); + 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()), Some("#ffffff".into()), "white on the black wedge"); + let red = Paint::solid(vectorcraft_color::Color::from_hex("#ff0000").unwrap()); + let styles = vectorcraft_doc::LabelStyles { legend: Some(vectorcraft_doc::CharStyle { fill: red, ..Default::default() }), ..Default::default() }; + assert!(!styles.legend_fill_set && styles.legend_fill_is_own(), "{styles:?}"); + s.edit("legacy", |d, _| { + d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles; + Ok(()) + }) + .unwrap(); + s.execute("file.documentColorMode", &json!({"mode": "rgb"})).unwrap(); + assert!(spec(&s, id).label_styles.legend_fill_set, "the converted red is still the labels' own"); +} + +/// The default black legend fill of a graph that keeps its legend beside the marks stays the default through a color +/// mode change, so turning on Legends in Wedges later still draws the labels on dark wedges white. +#[test] +fn a_color_mode_change_keeps_the_default_legend_fill_of_other_graphs() { + // A pie made here keeps its legend beside the pies. + for kind in ["column", "pie"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, kind, ",a,b\nP1,1,2"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 11})).unwrap(); + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let before = spec(&s, id).label_styles; + assert_eq!(before.legend.as_ref().map(|st| st.fill.clone()), Some(black.clone()), "{kind}: {before:?}"); + s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); + let after = spec(&s, id).label_styles; + assert_eq!(after.legend.as_ref().map(|st| st.fill.clone()), Some(black), "{kind}: {after:?}"); + assert!(!after.legend_fill_set && !after.legend_fill_is_own(), "{kind}: still the default: {after:?}"); + } +} + #[test] fn labels_are_told_apart_by_where_they_come_not_by_their_text() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2383d05a3a..f475aae760 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun}; use vectorcraft_geom::{Rect, Vec2}; -use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or}; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, switch_or}; use crate::{Clipboard, EngineError}; pub fn specs() -> Vec { @@ -137,19 +137,16 @@ pub(crate) fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result = t["selected"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_u64) - .filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len())) - .collect(); + let mut picked: Vec = + t["selected"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(index).filter(|i| *i < d.artboards.len()).collect(); if picked.is_empty() { - picked.push(t["active"].as_u64().unwrap_or(0)); + picked.push(on_board(d, t["active"].as_u64().map_or(0, index))?); } - artboard_indices(d, &json!({ "indices": picked }), cmd) + picked.sort_unstable(); + picked.dedup(); + Ok(picked) } /// Rename Artboards: one artboard takes the name, and several take it numbered in artboard order, in @@ -221,23 +218,51 @@ fn artboard_switch_orientation(s: &mut Session, p: &Value) -> Result { Ok(json!({ "switched": switched })) } -/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of -/// [`artboard_indices`] (`missing index`, `no such artboard`). +/// The one artboard `cmd` acts on: `index`, an artboard of `d`. Left out or null, it is +/// `missing index`; a value that is not a whole number is "`index` must be a whole number"; a +/// whole number past the last artboard is `no such artboard`. pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result { - let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?; - artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into())) + artboard_at(d, p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index")) +} + +/// The artboard of `d` that `key` of `p` names, or `None` when `p` leaves it out or gives null. A +/// value that is not a whole number is "`{key}` must be a whole number", and a whole number past the +/// last artboard is `no such artboard`. +pub(crate) fn artboard_at(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result> { + super::index_opt(p, key, cmd)?.map(|i| on_board(d, i)).transpose() +} + +/// `i` when it is an artboard of `d`, else `no such artboard`. +pub(crate) fn on_board(d: &vectorcraft_doc::Document, i: usize) -> Result { + match i < d.artboards.len() { + true => Ok(i), + false => Err(EngineError::Other("no such artboard".into())), + } } /// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`, -/// in order and once each. +/// in order and once each. `indices` is read as [`listed_artboards`] reads it, and `index` as +/// [`artboard_index`] reads it. pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result> { - let listed = match (p.get("indices"), p.get("index")) { - (Some(v), _) => point_indices(v), - (None, Some(i)) => point_indices(&json!([i])), - (None, None) => return Err(bad(cmd, "missing index")), - }; - let mut indices = - listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + match p.get("indices") { + Some(v) => listed_artboards(d, v, "indices", cmd), + None => Ok(vec![artboard_index(d, p, cmd)?]), + } +} + +/// `v`, the value of `key`, as artboards of `d`, in order and once each. A value that is not a list +/// is "`{key}` must be a list of artboard indices", an empty list is "`{key}` must be a non-empty +/// list of artboard indices", and a list with an entry that is not a whole number is "`{key}` must +/// contain whole numbers"; a whole number past the last artboard is `no such artboard`. Every entry +/// is checked for its form before any is checked against the artboards. +fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result> { + let entries = v.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be a list of artboard indices")))?; + if entries.is_empty() { + return Err(bad(cmd, format!("`{key}` must be a non-empty list of artboard indices"))); + } + let listed = entries.iter().map(Value::as_u64).collect::>>(); + let listed = listed.ok_or_else(|| bad(cmd, format!("`{key}` must contain whole numbers")))?; + let mut indices = listed.into_iter().map(|i| on_board(d, index(i))).collect::>>()?; indices.sort_unstable(); indices.dedup(); Ok(indices) diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index ac528f3735..063444442e 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -38,7 +38,7 @@ use std::collections::HashMap; -use craft_xml::{Canonical, Doctype, El, Limits, Names, Options}; +use roxmltree::Node; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; @@ -88,7 +88,6 @@ const MAX_RUNS: usize = 100_000; /// WordprocessingML's namespaces: Transitional, then Strict. const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]; -const DOCX_NS: Canonical<'static> = Canonical::new(&[("w", W[0]), ("w", W[1])]); /// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds /// `word/document.xml`? @@ -105,7 +104,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result { let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into()); let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) }; let doc = parse(&xml, &main, cmd)?; - if local(&doc) != Some("document") { + if local(doc.root_element()) != Some("document") { return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document")); } let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default(); @@ -118,7 +117,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result { if let Some(xml) = part(&zip, &styles, cmd)? { r.styles(&parse(&xml, &styles, cmd)?); } - if let Some(body) = child(&doc, "body") { + if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) { r.block(body, 0); } r.finish(cmd) @@ -159,17 +158,57 @@ fn part(zip: &Archive, name: &str, cmd: &str) -> Result> { } /// The XML of the part `name`. -fn parse(xml: &str, name: &str, cmd: &str) -> Result { - let opts = Options { - names: Names::Canonical(&DOCX_NS), - doctype: Doctype::Reject, - limits: Limits { max_depth: MAX_NESTING, max_nodes: MAX_NODES as usize, ..Limits::DEFAULT }, - ..Options::default() +fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result> { + // roxmltree parses nested elements recursively. + if nesting(xml) > MAX_NESTING { + return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() }; + roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}"))) +} + +/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, +/// CDATA sections, processing instructions, declarations and quoted attribute values. +pub(crate) fn nesting(xml: &str) -> usize { + let b = xml.as_bytes(); + let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); + // The index just past the first `end` at or after `from` (the end of the text if none). + let past = |from: usize, end: &[u8]| { + b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) }; - opts.parse(xml.as_bytes()).map_err(|e| match e { - craft_xml::Error::TooDeep(_) => bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")), - _ => bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")), - }) + while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { + let start = i + at; + let rest = b.get(start..).unwrap_or_default(); + if rest.starts_with(b""); + } else if rest.starts_with(b"") { + i = past(start + 9, b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else { + // A start tag: up to its `>` outside quotes; `/>` closes it at once. + let mut j = start + 1; + let mut quote = None; + while let Some(&c) = b.get(j) { + match (quote, c) { + (None, b'"' | b'\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (None, b'>') => break, + _ => {} + } + j += 1; + } + if b.get(j.saturating_sub(1)) != Some(&b'/') { + depth += 1; + most = most.max(depth); + } + i = j + 1; + } + } + most } /// The part that the first relationship of part `source` (`""`: the package) whose type ends in @@ -181,10 +220,11 @@ fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result String { } /// `n`'s local name when it is a WordprocessingML element. -fn local(n: &El) -> Option<&str> { - n.name.strip_prefix("w:") +fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> { + (n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name()) } /// `n`'s first WordprocessingML child element named `name`. -fn child<'a>(n: &'a El, name: &str) -> Option<&'a El> { - n.elements().find(|c| local(c) == Some(name)) +fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option> { + n.children().find(|c| local(*c) == Some(name)) } /// `n`'s WordprocessingML attribute `name`. -fn attr<'a>(n: &'a El, name: &str) -> Option<&'a str> { - n.attrs.iter().find_map(|(k, v)| k.strip_prefix("w:").filter(|&l| l == name).map(|_| v.as_str())) +fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> { + n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value()) } /// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`. -fn on(n: &El) -> bool { +fn on(n: Node) -> bool { !matches!(attr(n, "val"), Some("false" | "0" | "off")) } @@ -240,12 +280,12 @@ fn measure(v: &str, per_pt: f64) -> Option { } /// Twentieths of a point. -fn twips(n: &El, name: &str) -> Option { +fn twips(n: Node, name: &str) -> Option { attr(n, name).and_then(|v| measure(v, 20.0)) } /// Half points. -fn half_points(n: &El) -> Option { +fn half_points(n: Node) -> Option { attr(n, "val").and_then(|v| measure(v, 2.0)) } @@ -422,21 +462,21 @@ struct Reader { impl Reader { /// The major and minor fonts of a theme part's font scheme. - fn theme_fonts(&mut self, doc: &El) { + fn theme_fonts(&mut self, doc: &roxmltree::Document) { for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) { - *slot = std::iter::once(doc) - .chain(doc.descendants()) - .find(|n| n.local() == scheme) - .and_then(|n| n.elements().find(|c| c.local() == "latin")) - .and_then(|n| n.attr("typeface")) + *slot = doc + .descendants() + .find(|n| n.tag_name().name() == scheme) + .and_then(|n| n.children().find(|c| c.tag_name().name() == "latin")) + .and_then(|n| n.attribute("typeface")) .map(font_name) .filter(|f| !f.is_empty()); } } /// The document defaults and the styles of the styles part. - fn styles(&mut self, doc: &El) { - for n in doc.elements() { + fn styles(&mut self, doc: &roxmltree::Document) { + for n in doc.root_element().children() { match local(n) { Some("docDefaults") => { let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner)); @@ -486,7 +526,7 @@ impl Reader { } /// The number of the font a `w:rFonts` element names. - fn font(&mut self, n: &El) -> Option { + fn font(&mut self, n: Node) -> Option { let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref); let name = attr(n, "asciiTheme") .and_then(theme) @@ -509,9 +549,9 @@ impl Reader { } /// The run properties of a `w:rPr` element. - fn run_props(&mut self, rpr: &El) -> RunProps { + fn run_props(&mut self, rpr: Node) -> RunProps { let mut p = RunProps::default(); - for n in rpr.elements() { + for n in rpr.children() { match local(n) { Some("rFonts") => p.font = self.font(n), Some("b") => p.bold = Some(on(n)), @@ -554,11 +594,11 @@ impl Reader { } /// The paragraphs, tables and containers of them that are `n`'s children. - fn block(&mut self, n: &El, depth: usize) { + fn block(&mut self, n: Node, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.elements() { + for c in n.children() { if self.full { return; } @@ -577,7 +617,7 @@ impl Reader { } /// A table: each row a paragraph, its cells separated by tabs. - fn table(&mut self, tbl: &El, depth: usize) { + fn table(&mut self, tbl: Node, depth: usize) { for row in items(tbl, "tr", depth) { for cell in items(row, "tc", depth) { self.flush(); @@ -598,7 +638,7 @@ impl Reader { } /// A paragraph. - fn paragraph(&mut self, p: &El, depth: usize) { + fn paragraph(&mut self, p: Node, depth: usize) { self.flush(); let ppr = child(p, "pPr"); let style = ppr @@ -635,11 +675,11 @@ impl Reader { } /// The runs of a paragraph, and the containers of runs in it. - fn inline(&mut self, n: &El, depth: usize) { + fn inline(&mut self, n: Node, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.elements() { + for c in n.children() { match local(c) { Some("r") => self.run(c), Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1), @@ -654,7 +694,7 @@ impl Reader { } /// A run. - fn run(&mut self, r: &El) { + fn run(&mut self, r: Node) { let rpr = child(r, "rPr"); let mut chars = self.base; let style = rpr @@ -669,10 +709,11 @@ impl Reader { let props = self.run_props(n); chars.apply(&props, false); } - for c in r.elements() { + for c in r.children() { match local(c) { Some("t") => { - for ch in c.text().chars() { + // Comments and processing instructions may split the text. + for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) { self.put(ch, chars); } } @@ -866,9 +907,9 @@ fn font_name(name: &str) -> String { } /// The paragraph properties of a `w:pPr` element. -fn para_props(ppr: &El) -> ParaProps { +fn para_props(ppr: Node) -> ParaProps { let mut p = ParaProps::default(); - for n in ppr.elements() { + for n in ppr.children() { match local(n) { Some("jc") => { p.align = match attr(n, "val") { @@ -904,12 +945,12 @@ fn para_props(ppr: &El) -> ParaProps { } /// `n`'s children named `name`, and those inside content controls and custom XML among them. -fn items<'a>(n: &'a El, name: &str, depth: usize) -> Vec<&'a El> { +fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec> { let mut out = vec![]; if depth > MAX_DEPTH { return out; } - for c in n.elements() { + for c in n.children() { match local(c) { Some(l) if l == name => out.push(c), Some("sdt") => { @@ -938,6 +979,15 @@ mod tests { assert_eq!(measure("wide", 20.0), None); } + #[test] + fn nesting_counts_elements_outside_markup_that_is_not_an_element() { + assert_eq!(nesting(r#"]]>"#), 2); + assert_eq!(nesting(""), 3, "unclosed elements count"); + assert_eq!(nesting(""), 1); + assert_eq!(nesting(" = const { std::cell::Cell::new(0) }; + /// Tests: how many times [`story_in`] went through a thread's frames on this thread. + static STORY_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Tests: how many times [`story_of`] and [`ThreadIndex::of`] have gone through the document's @@ -409,6 +411,12 @@ pub(crate) fn thread_scans() -> usize { THREAD_SCANS.with(std::cell::Cell::get) } +/// Tests: how many times [`story_in`] has gone through a thread's frames on this thread. +#[cfg(test)] +pub(crate) fn story_builds() -> usize { + STORY_BUILDS.with(std::cell::Cell::get) +} + /// The frames of the thread that `id` is a frame of, in thread order, each area type or type on a /// path; empty when `id` is in no thread or the thread has fewer than two such frames. pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec { @@ -420,6 +428,8 @@ pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec { /// [`story_of`] for `id` in `thread`, the first of the document's threads that lists it. pub(crate) fn story_in(d: &Document, thread: &[NodeId], id: NodeId) -> Vec { + #[cfg(test)] + STORY_BUILDS.with(|c| c.set(c.get() + 1)); let frames: Vec = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect(); if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] } } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 73e2a78b31..05b45cc051 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -86,7 +86,7 @@ pub fn specs() -> Vec { "Set Text", [], None, - "{id?|ids?, text} replace the contents of text objects (keeps the first run's style) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", + "{id?|ids?, text} replace the contents of text objects (keeps the first run's style); no undo step when no object changes (each already has the text in one style, every paragraph with the first one's attributes, and no ruby) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", has_doc, set_text ), @@ -104,7 +104,7 @@ pub fn specs() -> Vec { "Reshape Type Area", [], None, - "{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step", + "{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step; no undo step when no frame changes (dx and dy both 0)", has_doc, reshape_area ), @@ -113,7 +113,7 @@ pub fn specs() -> Vec { "Reshape Type Path", [], None, - "{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path)", + "{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path); no undo step when the path does not change (dx and dy both 0)", has_doc, reshape_path ), @@ -122,7 +122,7 @@ pub fn specs() -> Vec { "Move Type Path Handle", [], None, - "{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)", + "{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path); no undo step when the handle is already there and the path does not change", has_doc, set_path_handle ), @@ -131,7 +131,7 @@ pub fn specs() -> Vec { "Fit Headline", ["Type"], None, - "{ids?|id?} track the first line of area type so it fills the frame width → {tracking}", + "{ids?|id?} track the first line of area type so it fills the frame width; no undo step when the first line already has that tracking → {tracking}", has_selection, fit_headline ), @@ -140,7 +140,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (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), 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), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (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), 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), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), 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_style ), @@ -149,7 +149,7 @@ pub fn specs() -> Vec { "Ruby…", [], None, - "{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}", + "{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead; no undo step when the ruby does not change (the same reading and options again, or `remove` where there is none) → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}", has_selection, ruby ), @@ -265,8 +265,8 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result { } /// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one -/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby -/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text). +/// undo step (none when nothing changes). Without `start`/`end` the range is all the text; a new +/// reading replaces the ruby over the range. The reply lists the first object's ruby (start/end in bytes of its plain text). fn ruby(s: &mut Session, p: &Value) -> Result { const C: &str = "type.ruby"; let ids = text_targets(s, p, C)?; @@ -293,7 +293,8 @@ fn ruby(s: &mut Session, p: &Value) -> Result { } let overhang = switch_or(p, "overhang", true, C)?; let mut first: Option = None; - s.edit("Ruby", |d, _| { + // The same reading again, or a removal where there is no ruby, is no undo step. + edit_if_nodes_changed(s, "Ruby", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); @@ -320,7 +321,7 @@ fn ruby(s: &mut Session, p: &Value) -> Result { first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null)); } } - Ok(()) + Ok(((), false)) })?; Ok(json!({"ids": ids.iter().map(|id| id.0).collect::>(), "ruby": first.unwrap_or(Value::Null)})) } @@ -755,6 +756,19 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// [`Session::edit`] for a type command that may set what the type already has: `f` runs on a +/// copy of the document, and it becomes an undo step only when one of the objects `ids` differs +/// afterwards or `f` returns true for a change of its own elsewhere (a graph's kept label styles). +/// When nothing changed, the document, its undo and redo steps and its saved state stay as they +/// were. Only the objects are compared, not the whole document, whose embedded images can be large. +fn edit_if_nodes_changed(s: &mut Session, label: &str, ids: &[NodeId], f: impl FnOnce(&mut Document) -> Result<(T, bool)>) -> Result { + super::colorcmds::edit_if_changed(s, label, |before, d| { + let (v, other) = f(d)?; + let changed = other || ids.iter().any(|id| before.node(*id) != d.node(*id)); + Ok((v, changed)) + }) +} + pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let ids = targets(s, p)?; let t = text_ids(&s.doc()?.doc, &ids); @@ -849,7 +863,8 @@ pub(crate) fn style_chars(t: &mut TextObject, range: Option, mut f: i fn set_text(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string(); let ids = text_targets(s, p, "text.setText")?; - s.edit("Typing", |d, _| { + // The text the type already has is no undo step. + edit_if_nodes_changed(s, "Typing", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let style = t.first_style(); @@ -860,7 +875,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result { t.ruby.clear(); refresh_bounds(t); } - Ok(()) + Ok(((), false)) })?; Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))) } @@ -933,7 +948,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result { let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some()); // The fonts the styled characters are set in, for the warning. let mut set = std::collections::BTreeSet::new(); - s.edit("Character", |d, _| { + // Attributes the characters already have are no undo step. + edit_if_nodes_changed(s, "Character", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?; @@ -1008,8 +1024,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result { } // Graph labels keep a change made to all of their text through the graph being drawn again (sizes as given, // not in a scaled label's own units). + let mut labels_changed = false; if range.is_none() { - super::graph::capture_label_styles(d, &ids, &|st| { + labels_changed = super::graph::capture_label_styles(d, &ids, &|st| { if let Some(f) = &font { st.font_family = f.clone(); st.font_version = None; @@ -1052,7 +1069,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { } }); } - Ok(()) + Ok(((), labels_changed)) })?; let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); if let Some(w) = super::fonts::set_font_warning(set) { @@ -1350,14 +1367,16 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { items.push((id, refs)); } let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); - s.edit("Reshape Type Area", |doc, _| { + // Anchors moved by no distance are no undo step. + let ids: Vec = items.iter().map(|(id, _)| *id).collect(); + edit_if_nodes_changed(s, "Reshape Type Area", &ids, |doc| { for (id, refs) in &items { let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) }; if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) { return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas")); } } - Ok(()) + Ok(((), false)) })?; ok() } @@ -1377,7 +1396,8 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not type on a path outside perspective")); } - s.edit("Reshape Type Path", |doc, _| { + // Anchors moved by no distance are no undo step. + edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| { let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let before = t.kind.clone(); let within = |t: &TextObject| { @@ -1388,7 +1408,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "no such path anchor, or the path would reach past the canvas")); } refresh_bounds(t); - Ok(()) + Ok(((), false)) })?; ok() } @@ -1406,7 +1426,8 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not type on a path outside perspective")); } - s.edit("Reshape Type Path", |doc, _| { + // A handle moved to where it is is no undo step. + edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| { let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let before = t.kind.clone(); let within = |t: &TextObject| { @@ -1417,7 +1438,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "no such path anchor, or the path would reach past the canvas")); } refresh_bounds(t); - Ok(()) + Ok(((), false)) })?; ok() } @@ -1428,6 +1449,8 @@ mod block; pub(crate) use block::needed_room; #[cfg(test)] mod block_tests; +#[cfg(test)] +mod noop_tests; /// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. /// The third value is where the first line ends in the plain text. @@ -1469,7 +1492,9 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result { plans.push((*id, (lo * 10.0).floor() / 10.0)); } let first = plans.first().map(|p| p.1).ok_or_else(|| bad(C, "select area type whose first line can fit its frame"))?; - s.edit("Fit Headline", |d, _| { + // A headline that already has its fitted tracking is no undo step. + let fitted: Vec = plans.iter().map(|(id, _)| *id).collect(); + edit_if_nodes_changed(s, "Fit Headline", &fitted, |d| { for (id, tr) in &plans { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { let end = t.plain_text().find('\n').unwrap_or(usize::MAX).min(vectorcraft_text::edit::runs_len(&t.runs)); @@ -1477,7 +1502,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result { refresh_bounds(t); } } - Ok(()) + Ok(((), false)) })?; Ok(json!({ "tracking": first })) } diff --git a/crates/engine/src/cmd/typecmd/noop_tests.rs b/crates/engine/src/cmd/typecmd/noop_tests.rs new file mode 100644 index 0000000000..02481d8bee --- /dev/null +++ b/crates/engine/src/cmd/typecmd/noop_tests.rs @@ -0,0 +1,143 @@ +use super::*; + +/// The document and the number of undo steps, to compare before and after a command that sets +/// what the type already has. +fn state(s: &Session) -> (std::sync::Arc, usize) { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len()) +} + +/// Assert that `cmd` with `p` leaves the document and its undo history as they were. +fn records_nothing(s: &mut Session, cmd: &str, p: Value) { + let before = state(s); + s.execute(cmd, &p).unwrap(); + let after = state(s); + assert!(*after.0 == *before.0, "{cmd} {p} changed the document"); + assert_eq!(after.1, before.1, "{cmd} {p} recorded an undo step"); +} + +/// Assert that `cmd` with `p` changes the document in exactly one undo step. +fn records_one_step(s: &mut Session, cmd: &str, p: Value) { + let before = state(s); + s.execute(cmd, &p).unwrap(); + let after = state(s); + assert!(*after.0 != *before.0, "{cmd} {p} left the document as it was"); + assert_eq!(after.1, before.1 + 1, "{cmd} {p} did not record one undo step"); +} + +fn session_with(text: &str, area: Option) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let mut p = json!({"x": 10, "y": 20, "text": text, "size": 24}); + if let Some(a) = area { + p["area"] = a; + } + let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + (s, id) +} + +/// Ruby set again with the same reading and options, or removed where there is none, records no +/// undo step; a step that does change the ruby still undoes in one. +#[test] +fn ruby_that_changes_nothing_records_no_undo_step() { + let (mut s, id) = session_with("漢字です", None); + records_nothing(&mut s, "type.ruby", json!({"remove": true})); + let p = json!({"text": "かんじ", "start": 0, "end": 6, "position": "below", "size": 60}); + records_one_step(&mut s, "type.ruby", p.clone()); + records_nothing(&mut s, "type.ruby", p); + s.execute("type.ruby", &json!({"remove": true})).unwrap(); + records_nothing(&mut s, "type.ruby", json!({"remove": true, "start": 0, "end": 6})); + s.execute("edit.undo", &json!({})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.ruby.len(), 1, "undo brings the ruby back"); +} + +/// `text.setStyle` (the Character panel) given the attributes the characters already have +/// records no undo step, with a range too. +#[test] +fn character_attributes_already_set_record_no_undo_step() { + let (mut s, _) = session_with("Headline", None); + records_nothing(&mut s, "text.setStyle", json!({"size": 24})); + s.execute("text.setStyle", &json!({"tracking": 50, "fill": "#ff0000"})).unwrap(); + records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "fill": "#ff0000"})); + records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "start": 0, "end": 4})); + s.execute("text.setStyle", &json!({"justify": "center"})).unwrap(); + records_nothing(&mut s, "text.setStyle", json!({"justify": "center"})); + records_one_step(&mut s, "text.setStyle", json!({"tracking": 80, "start": 0, "end": 4})); +} + +/// `text.setStyle` on a graph label that leaves the label's own text as it is but changes the +/// style its kind of label keeps still records one undo step; given again, it records none. +#[test] +fn a_kept_graph_label_style_change_alone_records_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4"; + let graph = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let doc = s.doc().unwrap().doc.clone(); + let legend = doc.node(graph).unwrap().children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap(); + let (label, size) = legend + .children() + .unwrap() + .iter() + .find_map(|c| match &c.kind { + NodeKind::Text(t) if t.plain_text() == "2025" => Some((c.id, t.runs[0].style.size)), + _ => None, + }) + .unwrap(); + let kept = |s: &Session| s.doc().unwrap().doc.node(graph).unwrap().extras().graph.as_deref().unwrap().label_styles.legend.clone(); + assert!(kept(&s).is_none()); + let p = json!({"ids": [label.0], "size": size}); + let before = state(&s); + s.execute("text.setStyle", &p).unwrap(); + let after = state(&s); + assert!(after.0.node(label) == before.0.node(label), "the label's own text changed"); + assert!(kept(&s).is_some(), "the legend keeps the style"); + assert_eq!(after.1, before.1 + 1, "the kept style change recorded no undo step"); + records_nothing(&mut s, "text.setStyle", p); +} + +/// `text.setText` with the text the type already has records no undo step; new text records one. +#[test] +fn the_same_text_records_no_undo_step() { + let (mut s, _) = session_with("Same words", None); + records_nothing(&mut s, "text.setText", json!({"text": "Same words"})); + records_one_step(&mut s, "text.setText", json!({"text": "Other words"})); +} + +/// Fit Headline run again on a headline it already fitted records no undo step; the first run +/// records one. +#[test] +fn fit_headline_again_records_no_undo_step() { + let (mut s, _) = session_with("HEADLINE\nbody text", Some(json!({"width": 300, "height": 120}))); + let start = state(&s); + let tracking = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].clone(); + let fitted = state(&s); + assert!(*fitted.0 != *start.0 && fitted.1 == start.1 + 1, "the first Fit Headline did not record one undo step"); + let before = state(&s); + assert_eq!(s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"], tracking); + let after = state(&s); + assert!(*after.0 == *before.0 && after.1 == before.1, "a second Fit Headline recorded an undo step"); +} + +/// Reshaping area type or type on a path by no distance, or moving a path handle to where it is, +/// records no undo step; a reshape that moves something records one. +#[test] +fn reshaping_type_by_nothing_records_no_undo_step() { + let (mut s, id) = session_with("Boxed words", Some(json!({"width": 200, "height": 100}))); + records_nothing(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 0, "dy": 0})); + records_one_step(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 20, "dy": 10})); + let path = + s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap(); + let on_path = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap()["id"].clone(); + records_nothing(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 0})); + records_one_step(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 20})); + let handle = json!({"id": on_path, "anchor": 0, "which": "out", "x": 100, "y": 250}); + records_one_step(&mut s, "text.setPathHandle", handle.clone()); + records_nothing(&mut s, "text.setPathHandle", handle); +} diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 63c1a6f382..e1f4ae0bd5 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -198,32 +198,31 @@ impl Library { /// [`without_doctype`] says. fn parse_library(xml: &str, cmd: &str) -> Result { let xml = without_doctype(xml, cmd)?; - let opts = craft_xml::Options { - names: craft_xml::Names::Local, - limits: craft_xml::Limits { max_depth: MAX_LIBRARY_NESTING, max_nodes: MAX_LIBRARY_NODES as usize, ..craft_xml::Limits::DEFAULT }, - ..craft_xml::Options::default() - }; - let doc = opts.parse(xml.as_bytes()).map_err(|e| match e { - craft_xml::Error::TooDeep(_) => bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep")), - _ => bad(cmd, format!("the variable library is not well-formed XML: {e}")), - })?; - let Some(root) = std::iter::once(&doc).chain(doc.descendants()).find(|n| n.is("variableSets")) else { + // roxmltree parses nested elements recursively. + if crate::cmd::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING { + return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() }; + let doc = + roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else { return Err(bad(cmd, "the file holds no `variableSets` element")); }; - let sets: Vec<&craft_xml::El> = root.children("variableSet").collect(); + let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); - for var in sets.iter().flat_map(|s| s.children("variables")).flat_map(|v| v.children("variable")) { - let name = named(var.attr("varName"), "varName", cmd)?; + for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { + let name = named(var.attribute("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { return Err(bad(cmd, format!("variable `{name}` is declared twice"))); } if declared.len() > MAX_LIBRARY_VARIABLES { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } - match var.attr("trait") { + match var.attribute("trait") { Some("textcontent") => { // `docRef="id('…')"` names the element of the variable's bound object. - let element = var.attr("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); if let Some(element) = element { refs.push((name.clone(), element.to_string())); } @@ -238,8 +237,10 @@ fn parse_library(xml: &str, cmd: &str) -> Result { } let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect(); let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new()); - for row in sets.iter().flat_map(|s| s.children("sampleDataSets")).flat_map(|d| d.children("sampleDataSet")) { - let name = named(row.attr("dataSetName"), "dataSetName", cmd)?; + for row in + sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet")) + { + let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?; if !seen.insert(name.clone()) { return Err(bad(cmd, format!("data set `{name}` is declared twice"))); } @@ -247,8 +248,8 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets"))); } let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new()); - for el in row.elements() { - let var = el.name.as_str(); + for el in row.children().filter(roxmltree::Node::is_element) { + let var = el.tag_name().name(); if !declared.contains(var) { return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); } @@ -258,7 +259,11 @@ fn parse_library(xml: &str, cmd: &str) -> Result { if !text_names.contains(var) { continue; } - let paras: Vec = el.children("p").map(craft_xml::El::deep_text).collect(); + let paras: Vec = el + .children() + .filter(|n| is(n, "p")) + .map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()) + .collect(); if !paras.is_empty() { values.insert(var.to_string(), DataValue::Text(paras.join("\n"))); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1e4629e448..86525e9214 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -111,6 +111,9 @@ pub struct Prefs { pub recent_fonts_count: u32, pub missing_glyph_protection: bool, pub highlight_alternate_glyphs: bool, + /// Type › Show Character Alternates: with a Type tool and one character selected, the canvas + /// shows that character's alternate glyphs beside it (on by default). + pub show_character_alternates: bool, /// Type › Highlight Substituted Fonts: on screen, text drawn in a substitute for a missing /// font is highlighted, in every document (on by default). pub highlight_substituted_fonts: bool, @@ -442,6 +445,7 @@ impl Default for Prefs { recent_fonts_count: 10, missing_glyph_protection: true, highlight_alternate_glyphs: false, + show_character_alternates: true, highlight_substituted_fonts: true, placeholder_text: true, typed_lists: true, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a87b47be0e..83e6368f8b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -415,14 +415,16 @@ impl Session { /// Measure the type not measured yet in open document `index`, which is in the back, in its own /// composite fonts, lent for the measuring, so its type has the bounds it will have when the - /// document comes to the front. The front document stays the one whose composite fonts are - /// registered. The names of the document's composite fonts its type was measured in are kept, - /// so its coming to the front does not measure that type again. + /// document comes to the front: type naming a composite font the document does not define is + /// measured as in a missing font, also when the front document defines one of that name, and + /// also when the document defines none. The front document stays the one whose composite fonts + /// are registered. The names of the document's composite fonts its type was measured in are + /// kept, so its coming to the front does not measure that type again. fn measure_back_document(&mut self, index: usize) { let Some(st) = self.docs.get(index) else { return }; let fonts = st.doc.composite_fonts.clone(); let db = vectorcraft_text::FontDb::global(); - let lent = db.lend_composite_fonts(&fonts); + let lent = db.lend_document_composite_fonts(&fonts); // A name another loan holds with a different definition is not lent: type set in it is // measured again when the document comes to the front. let own: HashSet = diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 01eb7f7132..dedcf2d8af 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3018,6 +3018,76 @@ fn live_paint_expand_keeps_the_faces_and_edges_that_draw() { assert_eq!(rows(&n, lower_stroke), rows(&before, lower_stroke)); } +/// `livePaint.info` lists every fill and stroke row of each face and edge, bottom first, and says +/// which rows draw by Expand's rule, while `fill`, `stroke` and `width` stay the top rows. +#[test] +fn live_paint_info_lists_every_appearance_row() { + 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 blue = || Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + let green = || Paint::solid(Color::rgb(0.0, 1.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 faded = |mut i: AppearanceItem, o: f32| { + match &mut i { + AppearanceItem::Fill(f) => f.opacity = o, + AppearanceItem::Stroke(st) => st.opacity = o, + } + i + }; + let mut s = session(); + let g = live(&mut s); + // A drawn lower fill and a half-opaque stroke under an unpainted top fill. + let lower = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), faded(stroke(blue(), 2.0), 0.5), fill(Paint::None)], 1.0); + let quiet = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red())), faded(fill(red()), 0.0)], 1.0); + let bare = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![], 1.0); + // Two strokes with a fill between them. + let two = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 4.0), fill(green()), stroke(blue(), 2.0)], 1.0); + let thin = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.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 hidden_edge = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![stroke(red(), 2.0)], 1.0); + s.edit("t", |d, _| { + d.node_mut(hidden_edge).unwrap().visible = false; + Ok(()) + }) + .unwrap(); + let parts = [lower, quiet, bare, two, thin, clear_edge, hidden_edge]; + assert_eq!(parts.iter().collect::>().len(), parts.len()); + + let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap(); + let part = |key: &str, id: NodeId| info[key].as_array().unwrap().iter().find(|p| p["id"] == id.0).unwrap().clone(); + let fill_row = |i: usize, p: Paint, o: f64, v: bool, drawn: bool| json!({"index": i, "kind": "fill", "paint": p.label(), "opacity": o, "visible": v, "drawn": drawn}); + let stroke_row = |i: usize, p: Paint, o: f64, v: bool, w: f64, drawn: bool| json!({"index": i, "kind": "stroke", "paint": p.label(), "opacity": o, "visible": v, "width": w, "drawn": drawn}); + + let f = part("faces", lower); + assert_eq!(f["fill"], json!(Paint::None.label()), "fill stays the top fill"); + assert!(f["bounds"].is_array()); + assert_eq!( + f["appearance"], + json!([fill_row(0, red(), 1.0, true, true), stroke_row(1, blue(), 0.5, true, 2.0, true), fill_row(2, Paint::None, 1.0, true, false)]) + ); + assert_eq!(part("faces", quiet)["appearance"], json!([fill_row(0, red(), 1.0, false, false), fill_row(1, red(), 0.0, true, false)])); + assert_eq!(part("faces", bare)["appearance"], json!([])); + + let e = part("edges", two); + assert_eq!((e["stroke"].clone(), e["width"].clone()), (json!(blue().label()), json!(2.0)), "stroke and width stay the top stroke's"); + assert_eq!( + e["appearance"], + json!([stroke_row(0, red(), 1.0, true, 4.0, true), fill_row(1, green(), 1.0, true, true), stroke_row(2, blue(), 1.0, true, 2.0, true)]) + ); + assert_eq!(part("edges", thin)["appearance"], json!([stroke_row(0, red(), 1.0, true, 0.0, false)])); + for id in [clear_edge, hidden_edge] { + assert_eq!(part("edges", id)["appearance"], json!([stroke_row(0, red(), 1.0, true, 2.0, false)]), "a part that is not shown draws no row"); + } +} + /// Merge carries a face's whole appearance when one of its rows draws, and carries nothing from a /// face that draws nothing. #[test] diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 6cbfd909a9..be79004be2 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -1034,14 +1034,39 @@ fn in_place_pastes_put_a_copy_on_each_artboard_named() { // `artboard` wins over `artboards`. let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2, "artboards": [0, 3]})).unwrap()); assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 5.0), "{cmd}"); - // A list that is empty, names an artboard the document lacks or names one twice, or isn't a - // list: an error, and nothing pasted. - let before = doc(&s).clone(); - for bad in [json!([]), json!([0, 4]), json!([1, 1]), json!([0, -1]), json!(2), json!("0,1")] { - assert!(s.execute(cmd, &json!({"artboards": bad})).is_err(), "{cmd} with artboards {bad}"); + // A list that is empty, names an artboard the document lacks or names one twice, holds a + // value that is not a whole number, or isn't a list, and an `artboard` that is not a whole + // number or not an artboard: an error naming the fault, and nothing pasted. Both keys are + // checked when both are given. + let (before, undo) = (doc(&s).clone(), s.doc().unwrap().history.undo.len()); + let (not_list, missing, not_whole) = ( + "`artboards` must be a list of artboard indices", + "`artboards` names an artboard the document does not have", + "`artboards` must contain whole numbers", + ); + let cases = [ + (json!({"artboards": []}), not_list), + (json!({"artboards": 2}), not_list), + (json!({"artboards": "0,1"}), not_list), + (json!({"artboards": [0, 4]}), missing), + (json!({"artboards": [1, 1]}), "`artboards` names an artboard twice"), + (json!({"artboards": [0, -1]}), not_whole), + (json!({"artboards": [0, 1.5]}), not_whole), + (json!({"artboards": [4, "1"]}), not_whole), + (json!({"artboard": 1, "artboards": [7]}), missing), + (json!({"artboard": 1, "artboards": ["1"]}), not_whole), + (json!({"artboard": 4}), "`artboard` must be the index of an artboard of the document"), + (json!({"artboard": -1}), "`artboard` must be a whole number"), + (json!({"artboard": 1.5}), "`artboard` must be a whole number"), + (json!({"artboard": "1"}), "`artboard` must be a whole number"), + (json!({"artboard": null}), "`artboard` must be a whole number"), + (json!({"artboard": "1", "artboards": [9]}), "`artboard` must be a whole number"), + ]; + for (p, want) in cases { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert_eq!(e, format!("invalid parameters for `{cmd}`: {want}"), "{cmd} {p}"); } - assert!(s.execute(cmd, &json!({"artboard": 1, "artboards": [7]})).is_err(), "{cmd}: a bad list with `artboard`"); - assert_eq!(doc(&s), &before, "{cmd}"); + assert_eq!((doc(&s), s.doc().unwrap().history.undo.len()), (&before, undo), "{cmd}"); } } diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 9eeb5813bc..e5e6174af0 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2023,6 +2023,260 @@ fn smoothing_leaves_paths_out_of_reach_and_straight_segments_alone() { assert_eq!(path(&s, line), before); } +/// The turn, in degrees, of `sp` at each of its anchors: round a closed path, and 0 at an open +/// path's first and last anchors. +fn smooth_anchor_turns(sp: &vectorcraft_geom::SubPath) -> Vec { + let (n, segs) = (sp.anchors.len(), sp.segment_count()); + (0..n) + .map(|i| { + if segs == 0 || !sp.closed && (i == 0 || i == n - 1) { + return 0.0; + } + let (a, b) = (sp.segment((i + segs - 1) % segs), sp.segment(i % segs)); + let tin = [a.p3 - a.p2, a.p3 - a.p1, a.p3 - a.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default(); + let tout = [b.p1 - b.p0, b.p2 - b.p0, b.p3 - b.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default(); + tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees() + }) + .collect() +} + +/// The turn of `sp` at its anchor on `at`, `None` when no anchor is there. +fn smooth_turn_at(sp: &vectorcraft_geom::SubPath, at: Point) -> Option { + let i = sp.anchors.iter().position(|a| a.p.distance(at) < 1e-9)?; + smooth_anchor_turns(sp).get(i).copied() +} + +/// An open polyline with a corner at (300, 300) whose angle inside is `inside` degrees: a 100 pt +/// leg along y = 300 from the left, then a 100 pt leg turning by 180 - `inside` degrees, each leg cut +/// into `per_leg` segments. Returns the path, the corner, and a drag along the path from 40 pt +/// before the corner to 40 pt after it. +fn smooth_corner_path(s: &mut Session, inside: f64, per_leg: usize) -> (NodeId, Point, Vec) { + let v = Point::new(300.0, 300.0); + let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians()); + let back = kurbo::Vec2::new(-100.0, 0.0); + let legs = per_leg as f64; + let mut pts: Vec = (0..per_leg).map(|k| v + back * (1.0 - k as f64 / legs)).collect(); + pts.extend((0..=per_leg).map(|k| v + d * (100.0 * k as f64 / legs))); + let anchors: Vec<_> = pts.iter().map(|p| json!({"x": p.x, "y": p.y})).collect(); + let id = smooth_test_path(s, &anchors, false); + let mut drag: Vec = (0..8).map(|k| v - kurbo::Vec2::new(40.0 - 5.0 * f64::from(k), 0.0)).collect(); + drag.extend((0..=8).map(|k| v + d * (5.0 * f64::from(k)))); + (id, v, drag) +} + +/// A Smooth drag over a corner keeps it where the path turns there by more than 57.5° (an angle +/// inside the corner under 122.5°), whether each leg is one segment or ten and at any Fidelity. A +/// wider corner is not kept: when the drag changes the path, no anchor is left turning as sharply +/// there, and a leg of ten segments at the default Fidelity always rounds it. +#[test] +fn the_smooth_tool_keeps_corners_sharper_than_its_threshold() { + let mut wrong = vec![]; + for inside in [30.0f64, 60.0, 90.0, 120.0, 122.4, 122.6, 125.0, 135.0, 150.0, 170.0] { + let kept = inside < 122.5; + for per_leg in [1, 10] { + for fidelity in [0.5, 2.5, 10.0] { + let mut s = session(); + let (id, v, drag) = smooth_corner_path(&mut s, inside, per_leg); + let before = path(&s, id); + let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect(); + s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": fidelity})).unwrap(); + let p = path(&s, id); + let turn = 180.0 - inside; + let at = smooth_turn_at(&p.subpaths[0], v); + let changed = p != before; + let rounded = changed && at.is_none_or(|t| t < turn - 1.0); + let right = if kept { at.is_some_and(|t| (t - turn).abs() < 0.01) } else { rounded || !changed && per_leg == 1 }; + let most = smooth_anchor_turns(&p.subpaths[0]).into_iter().fold(0.0, f64::max); + if !right || !kept && most > 57.5 || !kept && per_leg == 10 && fidelity == 2.5 && !rounded { + wrong.push(format!( + "{inside}° inside, {per_leg} segments a leg, Fidelity {fidelity}: turn {at:?}, changed {changed}, sharpest {most}°" + )); + } + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Three Smooth drags over a 90° and a 120° corner leave the corner where it was and as sharp, while +/// the first one smooths a wobble on the same leg. +#[test] +fn smooth_drags_again_and_again_keep_a_corner_in_place() { + for inside in [90.0f64, 120.0] { + let mut s = session(); + let v = Point::new(300.0, 300.0); + let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians()); + let mut anchors: Vec<_> = (0..=18).map(|k| json!({"x": 180.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 301.5 } else { 300.0 }})).collect(); + anchors.push(json!({"x": v.x, "y": v.y})); + let c = v + d * 100.0; + anchors.push(json!({"x": c.x, "y": c.y})); + let id = smooth_test_path(&mut s, &anchors, false); + let mut drag = smooth_drag_along(300.0, 175.0, 300.0); + drag.extend((1..=8).map(|k| v + d * (5.0 * f64::from(k)))); + let mut before = path(&s, id); + for pass in 1..=3 { + smooth_pass(&mut s, &drag); + let p = path(&s, id); + let at = smooth_turn_at(&p.subpaths[0], v); + assert!(at.is_some_and(|t| (t - (180.0 - inside)).abs() < 0.01), "{inside}°, pass {pass}: {at:?}"); + if pass == 1 { + assert!(p.anchor_count() < before.anchor_count(), "{inside}°: the wobble is smoothed: {} anchors", p.anchor_count()); + assert!(total_turning(&p) < total_turning(&before), "{inside}°: the path turns less"); + } + before = p; + } + } +} + +/// A Smooth drag along a line with 1 pt jags, each turning by 90°, smooths them all: none is kept +/// as a corner, since the line hardly turns over the tool's reach. +#[test] +fn smoothing_keeps_no_small_jag_as_a_corner() { + let mut s = session(); + let anchors: Vec<_> = (0..=200).map(|i| json!({"x": 100.0 + f64::from(i), "y": if i % 2 == 1 { 201.0 } else { 200.0 }})).collect(); + let id = smooth_test_path(&mut s, &anchors, false); + assert!(smooth_anchor_turns(&path(&s, id).subpaths[0]).iter().skip(1).take(199).all(|t| (t - 90.0).abs() < 1e-6)); + smooth_pass(&mut s, &smooth_drag_along(200.5, 90.0, 310.0)); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.anchors.len() < 50, "the jags are smoothed: {} anchors", sp.anchors.len()); + let most = smooth_anchor_turns(sp).into_iter().fold(0.0, f64::max); + assert!(most <= 57.5, "a jag is kept, turning {most}°"); +} + +/// A closed path with 90° corners at (200, 200), where it starts, and at (400, 200), 75° ones at +/// (400, 400) and (200, 400), a 30° bend at (300, 426.79), and a top side jagged by 1 pt. With +/// `from_bend` the path starts at the bend instead. +fn smooth_house(s: &mut Session, from_bend: bool) -> (NodeId, [Point; 4], Point) { + let corners = [Point::new(200.0, 200.0), Point::new(400.0, 200.0), Point::new(400.0, 400.0), Point::new(200.0, 400.0)]; + let bend = Point::new(300.0, 400.0 + 100.0 / 75f64.to_radians().tan()); + let mut anchors = vec![json!({"x": 200.0, "y": 200.0})]; + anchors.extend((1..40).map(|k| json!({"x": 200.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 201.0 } else { 200.0 }}))); + for p in [corners[1], corners[2], bend, corners[3]] { + anchors.push(json!({"x": p.x, "y": p.y})); + } + if from_bend { + let at_bend = anchors.len() - 2; + anchors.rotate_left(at_bend); + } + (smooth_test_path(s, &anchors, true), corners, bend) +} + +/// A Smooth drag round the whole of a closed path, and one along its top side only, keep its corners, +/// the one where the path starts included, and smooth the jags; the path still starts where it did, +/// at a corner or at the bend, which the refit keeps, and the bend out of the second drag's reach +/// stays as it was. +#[test] +fn smoothing_a_closed_path_keeps_its_corners() { + let turns: Vec = { + let mut s = session(); + let (id, corners, _) = smooth_house(&mut s, false); + corners.iter().map(|c| smooth_turn_at(&path(&s, id).subpaths[0], *c).unwrap()).collect() + }; + for from_bend in [false, true] { + let mut s = session(); + let (id, corners, bend) = smooth_house(&mut s, from_bend); + let before = path(&s, id); + let mut ring: Vec = vec![]; + for (a, b) in [(corners[0], corners[1]), (corners[1], corners[2]), (corners[2], bend), (bend, corners[3]), (corners[3], corners[0])] { + let n = (a.distance(b) / 5.0).ceil(); + ring.extend((0..n as usize).map(|k| a.lerp(b, k as f64 / n))); + } + ring.push(corners[0]); + smooth_pass(&mut s, &ring); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "{} anchors", sp.anchors.len()); + assert_eq!(sp.anchors[0].p, before.subpaths[0].anchors[0].p, "the path still starts where it did"); + for (c, t) in corners.iter().zip(&turns) { + let at = smooth_turn_at(sp, *c); + assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "the corner at {c:?} turning {t}° is kept: {at:?}"); + } + } + + let mut s = session(); + let (id, corners, bend) = smooth_house(&mut s, false); + let before = path(&s, id); + smooth_pass(&mut s, &smooth_drag_along(200.0, 190.0, 410.0)); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "the top is smoothed: {} anchors", sp.anchors.len()); + for (c, t) in corners.iter().zip(&turns) { + let at = smooth_turn_at(sp, *c); + assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "part of the path: the corner at {c:?} turning {t}° is kept: {at:?}"); + } + assert!(smooth_turn_at(sp, bend).is_some_and(|t| (t - 30.0).abs() < 0.01), "the bend out of reach stays"); +} + +/// A Smooth drag over an anchor whose curved legs meet at a corner keeps the corner, taking the turn +/// from the directions the curves arrive and leave along, a handle of no length included. +#[test] +fn smoothing_keeps_a_corner_between_curves() { + use kurbo::ParamCurve; + let v = Point::new(200.0, 300.0); + let with_in = json!({"x": 200.0, "y": 300.0, "in": [170.0, 270.0], "out": [230.0, 270.0]}); + let no_in = json!({"x": 200.0, "y": 300.0, "out": [230.0, 270.0]}); + for (corner, first_out) in [(with_in, [130.0, 260.0]), (no_in, [170.0, 230.0])] { + let mut s = session(); + let anchors = [json!({"x": 100.0, "y": 300.0, "out": first_out}), corner, json!({"x": 300.0, "y": 300.0, "in": [270.0, 260.0]})]; + let id = smooth_test_path(&mut s, &anchors, false); + let before = path(&s, id); + let turn = smooth_turn_at(&before.subpaths[0], v).unwrap(); + assert!(turn > 80.0, "{turn}"); + let (a, b) = (before.subpaths[0].segment(0), before.subpaths[0].segment(1)); + let mut drag: Vec = (0..=10).map(|k| a.eval(0.6 + 0.04 * f64::from(k))).collect(); + drag.extend((1..=10).map(|k| b.eval(0.04 * f64::from(k)))); + smooth_pass(&mut s, &drag); + let at = smooth_turn_at(&path(&s, id).subpaths[0], v); + assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° is kept: {at:?}"); + } +} + +/// A corner at the first anchor of the stretch a drag smooths, just out of its reach, keeps its +/// place and its turn. +#[test] +fn a_corner_at_the_end_of_the_smoothed_stretch_stays() { + let mut s = session(); + let mut anchors = vec![json!({"x": 100.0, "y": 300.0}), json!({"x": 200.0, "y": 300.0})]; + anchors.extend((1..=20).map(|k| json!({"x": if k % 2 == 1 { 201.0 } else { 200.0 }, "y": 300.0 - 5.0 * f64::from(k)}))); + let id = smooth_test_path(&mut s, &anchors, false); + let before = path(&s, id); + let turn = smooth_turn_at(&before.subpaths[0], Point::new(200.0, 300.0)).unwrap(); + let drag: Vec = (0..=19).map(|k| Point::new(200.0, 287.0 - 5.0 * f64::from(k))).collect(); + smooth_pass(&mut s, &drag); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert_ne!(p, before, "the jagged leg is smoothed"); + assert_eq!(sp.anchors[0], before.subpaths[0].anchors[0]); + let at = smooth_turn_at(sp, Point::new(200.0, 300.0)); + assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° stays: {at:?}"); +} + +/// A Smooth drag over corners it keeps, with nothing else to smooth, changes nothing and adds no +/// undo step: an open path's corner and a square's four corners. +#[test] +fn a_smooth_drag_that_only_meets_corners_adds_no_undo_step() { + let mut s = session(); + let (id, v, drag) = smooth_corner_path(&mut s, 90.0, 1); + let before = path(&s, id); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(smooth_pass_ids(&mut s, &drag), json!([]), "a corner at {v:?}"); + assert_eq!(path(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + + let square = [(100.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y})); + let id = smooth_test_path(&mut s, &square, true); + let before = path(&s, id); + let undo = s.doc().unwrap().history.undo.len(); + let mut ring = smooth_drag_along(100.0, 100.0, 200.0); + ring.extend((1..=20).map(|k| Point::new(200.0, 100.0 + 5.0 * f64::from(k)))); + ring.extend(smooth_drag_along(200.0, 100.0, 200.0).into_iter().rev().skip(1)); + ring.extend((1..=20).map(|k| Point::new(100.0, 200.0 - 5.0 * f64::from(k)))); + assert_eq!(smooth_pass_ids(&mut s, &ring), json!([]), "a square"); + assert_eq!(path(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + #[test] fn join_tool_joins_two_lines() { let mut s = session(); diff --git a/crates/engine/src/tests/globaledit.rs b/crates/engine/src/tests/globaledit.rs index fa86248cdb..9a4cb7d138 100644 --- a/crates/engine/src/tests/globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_geom::{Affine, PathData, Rect}; +use vectorcraft_geom::{Affine, PathData, Point, Rect}; use crate::*; @@ -320,16 +320,16 @@ fn global_edit_needs_one_supported_object() { let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); sel(&mut s, &[t]); assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type"); - let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0); - // A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the - // message says so. - let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0); - sel(&mut s, &[turned]); - s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + // A reflected copy is not taken, and the message says so. + let lone = kite(&mut s, 0.0, 500.0, 1.0); + let mirrored = kite(&mut s, 200.0, 500.0, 1.0); + sel(&mut s, &[mirrored]); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); sel(&mut s, &[lone]); let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string(); - assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}"); + assert!(e.contains("no similar objects") && e.contains("reflected copies are not matched") && !e.contains("rotated"), "{e}"); assert!(s.doc().unwrap().global_edit.is_none()); + assert!(crate::cmd::globaledit::highlights(&s).is_empty(), "and it is not highlighted"); // A group holding type is not offered, though another such group exists. let group = |s: &mut Session, x: f64| { let p = rect(s, x, 100.0, 10.0, 10.0); @@ -851,3 +851,159 @@ fn groups_nested_as_deep_as_a_document_allows_are_edited_together() { s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); assert_eq!(fill(&s, other_inner), "#12ab34"); } + +/// A four-sided path with no symmetry, `k` times the size, its box's top left near (`x`, `y`): a +/// 30 × 70 rectangle whose first anchor is moved. +fn kite(s: &mut Session, x: f64, y: f64, k: f64) -> NodeId { + let id = rect(s, x, y, 30.0 * k, 70.0 * k); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 10.0 * k, "dy": -20.0 * k})).unwrap(); + id +} + +/// Rotate `id` by `deg` (counter-clockwise) about the centre of its box → that rotation. +fn rotate(s: &mut Session, id: NodeId, deg: f64) -> Affine { + let c = bounds(s, id).center(); + sel(s, &[id]); + s.execute("object.rotate", &json!({"angle": deg})).unwrap(); + Affine::rotate_about(-deg.to_radians(), c) +} + +/// A kite at the origin side of the page, a copy of it turned a quarter turn, and a copy at half +/// the size turned by 30° → the three and the transforms that lay the first on the other two. +fn turned_kites(s: &mut Session) -> ([NodeId; 3], [Affine; 2]) { + let a = kite(s, 0.0, 100.0, 1.0); + let (b, c) = (kite(s, 200.0, 100.0, 1.0), kite(s, 400.0, 100.0, 0.5)); + let quarter = rotate(s, b, 90.0) * Affine::translate((200.0, 0.0)); + let small = rotate(s, c, 30.0) * Affine::translate((400.0, 100.0)) * Affine::scale(0.5) * Affine::translate((0.0, -100.0)); + assert!(placed(s, a, b, quarter) && placed(s, a, c, small), "the copies as made"); + ([a, b, c], [quarter, small]) +} + +#[test] +fn start_global_edit_takes_rotated_copies_and_an_edit_reaches_each_turned_by_its_angle() { + let mut s = session(); + let ([a, b, c], [quarter, small]) = turned_kites(&mut s); + sel(&mut s, &[a]); + assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "both are highlighted before the start"); + assert_eq!(s.execute("select.globalEditOptions", &json!({})).unwrap()["count"], 3); + assert_eq!(start(&mut s, a, json!({})), [a, b, c]); + let before = (*s.doc().unwrap().doc).clone(); + let steps = s.doc().unwrap().history.undo.len(); + // An anchor moved on the selected kite moves on each copy along the copy's own directions. + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 12, "dy": 5})).unwrap(); + assert!(placed(&s, a, b, quarter), "the quarter-turned copy"); + assert!(placed(&s, a, c, small), "the smaller copy turned by 30°"); + assert!(!placed(&s, a, b, Affine::translate((200.0, 0.0))), "the copy is still turned"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + crate::tests_large_edits::undo_gives_back(&mut s, &before); + // A move of the whole kite to the right moves the quarter-turned copy up (counter-clockwise). + sel(&mut s, &[a]); + let was = bounds(&s, b); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + let now = bounds(&s, b); + assert!((now.x0 - was.x0).abs() < 1e-6 && (now.y0 - (was.y0 - 10.0)).abs() < 1e-6, "{was:?} → {now:?}"); + assert!(placed(&s, a, b, quarter) && placed(&s, a, c, small), "each copy lies on the kite as before"); +} + +#[test] +fn a_rotated_copy_leads_an_edit_as_any_other_member_does() { + let mut s = session(); + let ([a, b, c], [quarter, small]) = turned_kites(&mut s); + // Started on the turned copy, and edited from the smaller one. + assert_eq!(start(&mut s, b, json!({})), [b, a, c]); + s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 2]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 3, "dy": -4})).unwrap(); + assert!(placed(&s, a, b, quarter) && placed(&s, a, c, small)); + // A fill reaches the turned copies as it is. + s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); + assert_eq!((fill(&s, a), fill(&s, b)), ("#12ab34".to_string(), "#12ab34".to_string())); +} + +#[test] +fn size_takes_a_rotated_copy_of_the_same_size_and_other_shapes_stay_out() { + let mut s = session(); + let ([a, b, c], _) = turned_kites(&mut s); + // A quarter-turned copy is 70 × 30 by its box: it has the size of the 30 × 70 kite all the same. + assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "not the half-size copy {c}"); + s.execute("select.globalEdit", &json!({})).unwrap(); + // A copy turned and then stretched, and one whose anchor was moved, are other shapes. + let stretched = kite(&mut s, 0.0, 300.0, 1.0); + rotate(&mut s, stretched, 30.0); + s.execute("object.scale", &json!({"sx": 150, "sy": 100})).unwrap(); + let bent = kite(&mut s, 200.0, 300.0, 1.0); + rotate(&mut s, bent, 45.0); + s.execute("select.anchors", &json!({"id": bent.0, "anchors": [[0, 2]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 1, "dy": 0})).unwrap(); + // A copy turned by half a turn and by a full turn in two steps. + let half = kite(&mut s, 400.0, 300.0, 1.0); + rotate(&mut s, half, 180.0); + let full = kite(&mut s, 600.0, 300.0, 1.0); + rotate(&mut s, full, 135.0); + rotate(&mut s, full, 225.0); + assert_eq!(start(&mut s, a, json!({})), [a, b, c, half, full], "not the stretched {stretched} nor the bent {bent}"); +} + +#[test] +fn a_square_whose_anchors_were_turned_is_a_rotated_copy() { + let mut s = session(); + let [a, b, ..] = squares(&mut s); + let turn = rotate(&mut s, b, 90.0) * Affine::translate((300.0, 0.0)); + assert_eq!(start(&mut s, a, json!({})).len(), 3, "the turned square is still taken, with the small one"); + // The first anchor of the turned square lies at another corner, and that is the one that moves. + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap(); + assert!(placed(&s, a, b, turn)); + assert!(!placed(&s, a, b, Affine::translate((300.0, 0.0)))); +} + +#[test] +fn rotated_groups_are_like_the_group_and_an_edit_inside_one_reaches_the_others_turned() { + let mut s = session(); + let (g, [sq, dot, _]) = card(&mut s, 0.0, 1.0); + let (copy, [csq, cdot, cbar]) = card(&mut s, 300.0, 1.0); + let turn = rotate(&mut s, copy, 90.0) * Affine::translate((300.0, 0.0)); + // The same three objects, each turned in place: not the group turned as a whole. + let (apart, objects) = card(&mut s, 500.0, 1.0); + for id in objects { + rotate(&mut s, id, 90.0); + } + assert_eq!(start(&mut s, g, json!({})), [g, copy], "not the group {apart} whose objects were turned one by one"); + sel(&mut s, &[dot]); + s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); + assert_eq!(fill(&s, cdot), "#12ab34"); + sel(&mut s, &[sq]); + s.execute("object.setBounds", &json!({"height": 20, "reference": 0})).unwrap(); + assert!(placed(&s, sq, csq, turn), "the square in the turned group is halved along the group's own direction"); + assert_eq!(kids(&s, copy), [csq, cdot, cbar], "each object keeps its id"); + s.execute("select.globalEdit", &json!({})).unwrap(); + // An object inside one group takes the object at its place in the turned group, as the + // objects of rotated copies of a group are edited together. + assert_eq!(start(&mut s, sq, json!({})), [sq, csq]); +} + +#[test] +fn a_rotated_symbol_instance_is_taken_and_a_reflected_one_is_not() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + let first = NodeId(s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap()["id"].as_u64().unwrap()); + let place = + |s: &mut Session, x: f64| NodeId(s.execute("symbol.place", &json!({"name": "Tile", "x": x, "y": 300})).unwrap()["id"].as_u64().unwrap()); + let (turned, mirrored) = (place(&mut s, 200.0), place(&mut s, 400.0)); + sel(&mut s, &[turned]); + s.execute("object.rotate", &json!({"angle": 30, "origin": [220, 320]})).unwrap(); + sel(&mut s, &[mirrored]); + s.execute("object.reflect", &json!({"axis": "vertical", "origin": [420, 320]})).unwrap(); + assert_eq!(start(&mut s, first, json!({})), [first, turned], "not the reflected instance {mirrored}"); + let xf = |s: &Session, id: NodeId| match node(s, id).kind { + NodeKind::SymbolInstance { xf, .. } => xf, + _ => panic!("not an instance"), + }; + let was = xf(&s, turned); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + // The instance turned by 30° counter-clockwise moves along its own direction. + let moved = Affine::rotate((-30f64).to_radians()) * Point::new(10.0, 0.0); + let (now, want) = (xf(&s, turned).as_coeffs(), (Affine::translate(moved.to_vec2()) * was).as_coeffs()); + assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}"); +} diff --git a/crates/engine/src/tests/history_document.rs b/crates/engine/src/tests/history_document.rs index 3c2c9b9eb4..9e05eabeac 100644 --- a/crates/engine/src/tests/history_document.rs +++ b/crates/engine/src/tests/history_document.rs @@ -79,18 +79,29 @@ fn the_source_stays_as_it_is() { assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved"); } -/// An index that names no state, or is not a whole number, is an error and changes nothing: no -/// document, no step and no Untitled number is used up. +/// An index that names no state (`no history state N`), or is not a whole number ("`index` must be +/// a whole number"), is an error and changes nothing: no document, no step and no Untitled number +/// is used up. #[test] fn a_bad_index_is_an_error_and_changes_nothing() { let mut s = three_rectangles_one_undone(); let source = s.doc().unwrap().doc.clone(); let untitled = s.peek_untitled(); - let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)]; - for index in bad { + let bad: [(Value, bool); 8] = [ + (json!(4), true), + (json!(99), true), + (json!(u64::MAX), true), + (json!(-1), false), + (json!(1.5), false), + (json!("1"), false), + (json!(true), false), + (json!([1]), false), + ]; + for (index, whole) in bad { let e = s.execute(CMD, &json!({ "index": index })).unwrap_err(); assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}"); - assert!(e.to_string().contains(&format!("no history state {index}")), "{e}"); + let want = if whole { format!("no history state {index}") } else { "`index` must be a whole number".to_string() }; + assert_eq!(e.to_string(), format!("invalid parameters for `{CMD}`: {want}"), "{index}"); assert_eq!(s.documents().len(), 1, "{index}"); assert_eq!(steps(&s), (2, 1), "{index}"); assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}"); diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 1c4287e9c1..700c17883d 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -611,15 +611,18 @@ fn edit_copy_and_cut_read_the_recorded_artboards() { s.execute("select.none", &json!({})).unwrap(); assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}"); let before = documents(&s); - for bad in [ - json!({"selectedArtboards": [9]}), - json!({"selectedArtboards": []}), - json!({"selectedArtboards": "1"}), - json!({"selectedArtboards": null}), - json!({"selectedArtboards": [1], "art": "yes"}), + for (bad, want) in [ + (json!({"selectedArtboards": [9]}), "no such artboard"), + (json!({"selectedArtboards": []}), "`selectedArtboards` must be a non-empty list of artboard indices"), + (json!({"selectedArtboards": "1"}), "`selectedArtboards` must be a list of artboard indices"), + (json!({"selectedArtboards": null}), "`selectedArtboards` must be a list of artboard indices"), + (json!({"selectedArtboards": [1, "0"]}), "`selectedArtboards` must contain whole numbers"), + (json!({"selectedArtboards": [1, -1]}), "`selectedArtboards` must contain whole numbers"), + (json!({"selectedArtboards": [1], "art": "yes"}), "`art` must be true or false"), ] { let err = s.execute(id, &bad).unwrap_err().to_string(); - assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}"); + assert!(err.ends_with(want), "{id} {bad}: {err}"); + assert!(err.contains(id) || want == "no such artboard", "{id} {bad}: {err}"); assert_eq!(documents(&s), before, "{id} {bad}"); assert!(s.clipboard.artboards.is_empty(), "{id} {bad}"); } diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index abb0bf31eb..e8c4e46af3 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1979,9 +1979,10 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() { assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default"); } -/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names -/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as -/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first +/// Artboard Options and Fit to Selected Art fail with `no such artboard` on a whole-number `index` +/// past the last artboard, with "`index` must be a whole number" on one that is negative, +/// fractional or a string, and with `missing index` on a null one, and the artboards stay as they +/// were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first /// artboard. #[test] fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() { @@ -1992,9 +1993,16 @@ fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboa s.execute("paint.setStroke", &json!({"none": true})).unwrap(); let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); let (was, n) = (boards(&s), undo_len(&s)); - for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] { + for (index, error) in [ + (json!(99), None), + (json!(-1), Some("`index` must be a whole number")), + (json!(1.5), Some("`index` must be a whole number")), + (json!("1"), Some("`index` must be a whole number")), + (Value::Null, Some("missing index")), + ] { for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] { - assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + let want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}")); + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}"); } } let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string(); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index c8b51033fe..a97d414500 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -257,6 +257,27 @@ fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canva } } +/// Each copy of a Transform effect draws the drop shadow listed before the effect under itself, +/// scaled with it, and Expand Appearance keeps those shadows: the square (20..40) has its shadow +/// 20 pt to its right, and its copy scaled 200 % and moved 80 pt down (10..50 across, 90..130 +/// down) has its own 40 pt to its right. +#[test] +fn each_copys_shadow_expands_as_the_canvas_draws_it() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 20, "height": 20})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let shadow = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"}); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 200, "scaleV": 200, "moveV": 80, "copies": 1}})); + let before = render(&s); + assert_eq!(before.pixel(70, 110), [0, 255, 0, 255], "the copy's shadow"); + run(&mut s, "effect.expandAppearance", json!({})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "{diff} pixels differ"); +} + /// A group's Transform effect runs after its member's own Offset Path in Expand Appearance as on /// the canvas: the 100 pt square offset to 120 pt and scaled by half expands 60 pt wide. #[test] diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 8174cf36d1..715bf2b8ce 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -492,9 +492,11 @@ fn new_and_renamed_artboards_trim_their_names() { assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]); } -/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an -/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the -/// artboards, the art and the clipboard stay as they were. +/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on a +/// whole-number `index` past the last artboard, with "`index` must be a whole number" on one that +/// is negative, fractional, a string, true or a list, and with `missing index` on a null one, +/// instead of acting on the Artboard tool's active artboard. The artboards, the art and the +/// clipboard stay as they were. #[test] fn artboard_copies_refuse_an_index_that_names_no_artboard() { let mut s = session(); @@ -502,10 +504,22 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() { s.execute("artboard.new", &json!({})).unwrap(); s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap()); let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len()); - for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] { + let not_whole = Some("`index` must be a whole number"); + let cases = [ + (json!(99), None), + (json!(u64::MAX), None), + (json!(-1), not_whole), + (json!(1.5), not_whole), + (json!("1"), not_whole), + (json!(true), not_whole), + (json!([1]), not_whole), + (serde_json::Value::Null, Some("missing index")), + ]; + for (index, error) in cases { for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] { let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) }; - assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + let want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}")); + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}"); } } let st = s.doc().unwrap(); @@ -513,6 +527,90 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() { assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied"); } +/// The artboard commands that take `indices` (and `selectedArtboards`, a replay of the journal) +/// name the fault: a value that is not a list, an empty list and a list with an entry that is not a +/// whole number each have their own message naming the key, and a whole number past the last +/// artboard is `no such artboard`, also when another entry is malformed after it. The artboards, +/// the art, the undo history and the clipboard stay as they were. +#[test] +fn artboard_lists_name_the_fault_and_the_key() { + let mut s = session(); + rect(&mut s); + s.execute("artboard.new", &json!({})).unwrap(); + let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len()); + let cmds = [ + ("artboard.duplicate", json!({})), + ("artboard.copy", json!({})), + ("artboard.cut", json!({})), + ("artboard.rename", json!({"name": "A"})), + ("artboard.switchOrientation", json!({})), + ("artboard.delete", json!({})), + ("artboard.fitToArt", json!({})), + ("artboard.move", json!({"dx": 10, "dy": 0})), + ("artboard.align", json!({"horizontal": "left"})), + ]; + for key in ["indices", "selectedArtboards"] { + let cases = [ + (json!(null), format!("`{key}` must be a list of artboard indices")), + (json!(1), format!("`{key}` must be a list of artboard indices")), + (json!("0,1"), format!("`{key}` must be a list of artboard indices")), + (json!([]), format!("`{key}` must be a non-empty list of artboard indices")), + (json!([0, -1]), format!("`{key}` must contain whole numbers")), + (json!([0, 1.5]), format!("`{key}` must contain whole numbers")), + (json!(["1"]), format!("`{key}` must contain whole numbers")), + (json!([null]), format!("`{key}` must contain whole numbers")), + (json!([9, "1"]), format!("`{key}` must contain whole numbers")), + (json!([0, 9]), "no such artboard".to_string()), + ]; + for (cmd, base) in &cmds { + if key == "selectedArtboards" && matches!(*cmd, "artboard.delete" | "artboard.fitToArt" | "artboard.move") { + continue; + } + for (value, want) in &cases { + let mut p = base.clone(); + p[key] = value.clone(); + let want = if want.starts_with('`') { format!("invalid parameters for `{cmd}`: {want}") } else { want.clone() }; + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}"); + } + } + } + let st = s.doc().unwrap(); + assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n)); + assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied"); +} + +/// Align, Distribute and Distribute Spacing for artboards name the key that holds a malformed +/// artboard to align to (`artboard` or `activeArtboard`), not `index`; a null one is left out, and +/// a whole number past the last artboard is `no such artboard`. +#[test] +fn artboard_align_names_the_key_of_a_malformed_reference() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.new", &json!({})).unwrap(); + let cmds = [ + ("artboard.align", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})), + ("artboard.distribute", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})), + ("artboard.distributeSpacing", json!({"indices": [0, 1, 2], "to": "artboard"})), + ]; + for (cmd, base) in &cmds { + for key in ["artboard", "activeArtboard"] { + let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len()); + for value in [json!(-1), json!(1.5), json!("1"), json!(true)] { + let mut p = base.clone(); + p[key] = value; + let want = format!("invalid parameters for `{cmd}`: `{key}` must be a whole number"); + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}"); + } + let mut p = base.clone(); + p[key] = json!(9); + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + assert_eq!((boards(&s), s.doc().unwrap().history.undo.len()), (was, n), "{cmd} {key}: nothing moved"); + p[key] = json!(null); + s.execute(cmd, &p).unwrap(); + } + } +} + /// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an /// option given as text or as another value that is not true or false, such as "true", and /// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index d88b8250a2..521d2eabde 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -1978,6 +1978,26 @@ fn highlight_alternate_glyphs_preference_is_off_by_default() { assert!(!s.prefs.highlight_alternate_glyphs); } +/// Preferences › Type › Show Character Alternates: on by default, listed in the Type category, +/// saved with the other preferences, and a value that is not true or false is refused. +#[test] +fn show_character_alternates_preference_is_on_by_default_and_saved() { + let mut s = Session::new(); + assert!(s.prefs.show_character_alternates, "on by default"); + let spec = crate::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == "showCharacterAlternates").expect("listed"); + assert_eq!((spec.category, spec.label), ("Type", "Show Character Alternates")); + assert_eq!(s.execute("prefs.get", &json!({"key": "showCharacterAlternates"})).unwrap(), json!(true)); + set_pref(&mut s, "showCharacterAlternates", json!(false)); + assert!(!s.prefs.show_character_alternates); + assert!(s.execute("prefs.set", &json!({"key": "showCharacterAlternates", "value": "yes"})).is_err(), "not a boolean"); + assert!(!s.prefs.show_character_alternates, "a refused value changes nothing"); + let saved: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap(); + assert!(!saved.show_character_alternates); + // Preferences saved before the key existed read it as on. + let old: Prefs = serde_json::from_value(json!({"highlightAlternateGlyphs": true})).unwrap(); + assert!(old.show_character_alternates); +} + /// Preferences › Type › Highlight Substituted Fonts: on by default, listed in the Type category, /// and saved with the other preferences. /// diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 59b6b04e4e..6e963e70e4 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -10584,6 +10584,96 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() { same_wraps(&wraps(top), &[(Some("Bent".into()), 2.5, true)]); } +/// Type written as its outlines wraps area type below it by its bounds, as it did before saving, +/// not by its glyphs: its text wrap options go on an unpainted rectangle of its bounds, the last +/// member of the outlines' group, which keeps the type's name and has no options of its own. +/// Mirrored point type with a positive offset, a negative offset with Invert Wrap, hidden and +/// locked, type of only white space, and no room for the rectangle within the object limit. +#[test] +fn type_written_as_outlines_keeps_wrapping_by_its_bounds() { + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + type Region = Vec<([f64; 4], f64, bool)>; + // The wrap shapes of the area type, in its own space: each one's bounds and options. + let region = |doc: &Document| -> Region { + let mut doc = doc.clone(); + vectorcraft_doc::text::resolve_wrap(None, &mut doc); + let top = doc.layers[0].children().unwrap(); + let Some(NodeKind::Text(t)) = top.iter().map(|n| &n.kind).find(|k| matches!(k, NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }))) + else { + panic!("no area type"); + }; + t.wrap + .iter() + .map(|s| { + let b = s.path.bounds().unwrap_or_default(); + ([b.x0, b.y0, b.x1, b.y1], s.wrap.offset, s.wrap.invert) + }) + .collect() + }; + let make = |text: &str, offset: f64, invert: bool, visible: bool, locked: bool| { + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut body = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK)); + body.runs = vec![TextRun::new("one two three four five six seven eight nine ten eleven twelve", style(10.0, Color::BLACK))]; + body.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 290.0, 190.0)) }; + body.xf = Affine::IDENTITY; + let body = named(text_node(&mut doc, body), "Body"); + let mut label = TextObject::point(vectorcraft_geom::Point::ZERO, text, style(60.0, Color::BLACK)); + // Mirrored, so written as its outlines. + label.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 150.0, 120.0]); + let mut label = wrapped(named(text_node(&mut doc, label), "Label"), offset, invert); + (label.visible, label.locked) = (visible, locked); + for n in [body, label] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc + }; + for (text, offset, invert, visible, locked) in + [("Ill", 4.5, false, true, false), ("Ill", -2.0, true, true, false), ("Ill", 3.0, false, false, true), (" ", 3.0, false, true, false)] + { + let doc = make(text, offset, invert, visible, locked); + let bounds = doc.layers[0].children().unwrap()[1].geometric_bounds(); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (region(&doc), region(&back)); + let same = before.len() == after.len() + && before + .iter() + .zip(&after) + .all(|(b, a)| b.0.iter().zip(a.0).all(|(x, y)| (x - y).abs() < 1e-3) && (b.1 - a.1).abs() < 1e-6 && b.2 == a.2); + assert!(same, "{text:?}\n{before:?}\n{after:?}\n{text_out}"); + let top = back.layers[0].children().unwrap(); + let group = &top[1]; + assert!(matches!(group.kind, NodeKind::Group { clip: false, .. }), "{text_out}"); + assert_eq!((group.name.as_deref(), group.extras().wrap, group.visible, group.locked), (Some("Label"), None, visible, locked)); + if let Some(want) = bounds { + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}"); + same_wraps(&wraps(top), &[(None, offset, invert)]); + let last = group.children().unwrap().last().unwrap(); + assert!(matches!(last.kind, NodeKind::Path { guide: false, .. }) && last.appearance.items.is_empty(), "{text_out}"); + assert_eq!((last.visible, last.locked), (visible, locked)); + let b = last.geometric_bounds().unwrap(); + assert!([b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-3), "{b:?} {want:?}"); + } else { + assert!(wraps(top).is_empty(), "{text_out}"); + } + } + // The rectangle counts toward the object limit. Without room for it, the type's wrap options + // are left out with the limit's warning, and the glyphs do not take them. + let doc = make("Ill", 4.5, false, true, false); + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let all = export_within(&doc, &opts(), read).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings); + let out = export_within(&doc, &opts(), read - 1).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + let text_out = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(!text_out.contains("(AI11TextWrapOffset)"), "{text_out}"); + let (back, _) = crate::ai_alone(&out.program).unwrap(); + let top = back.layers[0].children().unwrap(); + assert!(wraps(top).is_empty(), "{text_out}"); + assert_eq!(top[1].name.as_deref(), Some("Label"), "{text_out}"); +} + /// 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, and /// with its strokes when the region outside leaves nothing of its fills. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 6361efe5ff..b9d7229186 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -9,7 +9,7 @@ use vectorcraft_color::swatch::{REGISTRATION, Swatch}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer, TextWrap}; use vectorcraft_effects::stroke; -use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath}; +use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath, shapes}; use vectorcraft_pathops::BoolOp; use crate::ai_ops::{self, unit}; @@ -271,6 +271,9 @@ pub(super) struct Writer<'a> { /// brushed stroke's pieces) the art being written is in. Text wrap options are written only /// outside them, since only the document's own objects wrap type. drawn: u32, + /// The type just written as outlines has its text wrap options on the rectangle of its bounds + /// ([`Self::wrap_bounds`]), not in the group's dictionary. + wrap_moved: bool, /// The brushes written ([`brushes`]). brush_set: brushes::Brushes, /// Brushes may be written; false on a large canvas, where they are left out. @@ -350,6 +353,7 @@ impl<'a> Writer<'a> { styles: styles::Styles::default(), in_look: false, drawn: 0, + wrap_moved: false, brush_set: brushes::Brushes::default(), brushes_live: true, deform: false, @@ -829,7 +833,8 @@ impl<'a> Writer<'a> { /// 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 moved = std::mem::take(&mut self.wrap_moved); + let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look && !moved); let placed = std::mem::take(&mut self.placed); let typed = std::mem::take(&mut self.typed); let bound = self.bound_variables(n, placed, typed); @@ -1404,7 +1409,8 @@ impl<'a> Writer<'a> { /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and /// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline /// graphics as their symbols' art where the layout places them, as the PDF page draws them; - /// the group has the type's transparency `xy`. + /// last, with text wrap options, the rectangle of its bounds ([`Self::wrap_bounds`]). The group + /// has the type's transparency `xy`. fn outlined_text(&mut self, n: &Node, xy: Xy) { if !self.open_group("u") { return; @@ -1460,12 +1466,34 @@ impl<'a> Writer<'a> { for c in over { self.node(c); } + // Type wraps by its bounds: its text wrap options go on a rectangle of its bounds, the + // group's last member, and the group's dictionary leaves them out. + if let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) { + self.wrap_bounds(n, w); + self.wrap_moved = true; + } if let Some((locked, hidden)) = state { self.state(locked, hidden); } self.end_group("U", xy); } + /// An unpainted closed rectangle at the geometric bounds of type `n`, the region its text wraps + /// around, with `n`'s lock and hidden state and text wrap options `w` in its dictionary. Nothing + /// when `n` has no finite bounds, or without room for it ([`Self::take_objects`]). + fn wrap_bounds(&mut self, n: &Node, w: TextWrap) { + let Some(b) = n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return }; + self.state(n.locked, !n.visible); + let objects = self.objects; + self.plain_object(&shapes::rectangle(b), FillRule::NonZero, None, None, Xy::PLAIN); + if self.objects == objects { + return; + } + self.body.push_str("%_/ArtDictionary :\n"); + self.text_wrap(w); + self.body.push_str("%_;\n%_\n"); + } + // ---------- color ---------- /// Set the fill (or with `stroke`, the stroke) to paint `p`: a spot or global swatch by its diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 43b088c66f..d7ef0623d9 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -648,10 +648,11 @@ fn named_cid_type_in_charpath_cshow_and_glyphshow() { } /// A font name ending in the row-and-cell CMap `H` or `V` stays a font of the whole name, its -/// strings read byte by byte, unless the part before the CMap names a CIDFont the file defines (or -/// an installed face that covers Japanese). Type in a predefined CMap whose mappings aren't -/// available is left out with a warning, each code moving the point one em, and a long string in a -/// CID-keyed font over a predefined CMap shown in a loop runs out of the budget. +/// strings read byte by byte, unless the part before the CMap names a CIDFont the file defines, a +/// Japanese CIDFont a printer carries (or an installed face that covers Japanese). Type in a +/// predefined CMap whose mappings aren't available is left out with a warning, each code moving +/// the point one em, and a long string in a CID-keyed font over a predefined CMap shown in a loop +/// runs out of the budget. #[test] fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { for (font, family, style) in [("Futura-H", "Futura", "H"), ("Foo-Bold-H", "Foo", "Bold-H"), ("Foo-Condensed-V", "Foo", "Condensed-V")] { @@ -675,6 +676,18 @@ fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { let r = super::tests::read(&format!("{define} findfont 20 scalefont setfont 10 20 moveto <467c> show")); assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["日"], "{define}: {:?}", r.warnings); } + // The Japanese CIDFonts a printer carries (Adobe Technical Note #5014, appendix A.2) split at + // `H` or `V` without the file defining them or a face of their name being installed. A face + // with the whole name, when installed, is that face's. + for (font, vertical) in [("Ryumin-Light-H", false), ("GothicBBB-Medium-V", true)] { + if vectorcraft_text::FontDb::global().find_postscript(font).is_some() { + continue; + } + let r = super::tests::read(&format!("/{font} findfont 20 scalefont setfont 10 20 moveto <467c> show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日"], "{font}: {:?}", r.warnings); + assert_eq!(t[0].vertical, vertical, "{font}"); + } let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill"); assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 15b9ca34c6..257fdc2247 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -509,9 +509,11 @@ impl Interp<'_> { /// Font name `name` as a CIDFont's name and the name of a predefined CMap other than a Unicode /// CMap ([`super::predefined_cmap::composite_font`]), unless an installed face has the whole /// name. A name that ends in the row-and-cell CMap `H` or `V` splits only when the part before - /// it names a CIDFont the file defines, or has a hyphen and names an installed face that covers - /// Japanese; otherwise it stays a font of the whole name (`Futura-H`, `Foo-Bold-H`), its - /// strings read byte by byte. + /// it names a CIDFont the file defines, one of the Japanese CIDFonts a printer carries + /// (`Ryumin-Light`, `GothicBBB-Medium`: Adobe Technical Note #5014, appendix A.2, + /// ), or has a + /// hyphen and names an installed face that covers Japanese; otherwise it stays a font of the + /// whole name (`Futura-H`, `Foo-Bold-H`), its strings read byte by byte. fn predefined_split<'a>(&mut self, name: &'a str) -> Option<(&'a str, &'a str)> { let (cid_font, cmap) = super::predefined_cmap::composite_font(name)?; let db = vectorcraft_text::FontDb::global(); @@ -525,7 +527,8 @@ impl Interp<'_> { let defined = self.instances(&Obj::name("CIDFont")).is_ok_and(|d| d.borrow().contains_key(&key)) || cid_font_in(&self.fonts); // 日 is in the row-and-cell CMaps' character set (JIS X 0208). let installed = || db.find_postscript(cid_font).is_some_and(|face| face.covers('日')); - if !(defined || (cid_font.contains('-') && installed())) { + let printer = matches!(cid_font, "Ryumin-Light" | "GothicBBB-Medium"); + if !(defined || printer || (cid_font.contains('-') && installed())) { return None; } } diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 616a11498d..1bc2d14442 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -668,18 +668,36 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} 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.tab", + "Show Learn Tab", + "", + "{tab: forYou|skills} show the Learn panel on its For you or Skills tab, as the tab buttons do; the Skills tab keeps the skill and tutorial it had open; a missing, non-string or unknown tab is an error and changes nothing; returns {tab, skill, tutorial}, with null for no skill or no tutorial open", + ), ( "learn.skills", "Back to Skills", "", "{} show the Learn panel on its Skills tab with the list of skills, with no skill and no tutorial open, as Back to skills after a skill's last tutorial and the Quick skill guide card do; returns what learn.list returns", ), + ( + "learn.skill", + "Show Skill", + "", + "{skill: id (see learn.list)} show the Learn panel on its Skills tab with that skill's tutorials, as choosing the skill's row does; a missing, non-string or unknown id is an error and changes nothing; returns the skill as learn.list lists it: {id, title, done, total, tutorials: [{id, title, steps, finished, completedSteps}]}", + ), ( "learn.show", "Show Tutorial", "", "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", ), + ( + "learn.back", + "Back One Level", + "", + "{} show the Learn panel and go up one level on its Skills tab, as its Back button does: from a tutorial's steps to its skill's tutorials, and from a skill's tutorials to the list of skills; on the list of skills or the For you tab the view stays as it is; returns {tab, skill, tutorial}, as learn.tab does", + ), ( "learn.locateTool", "Show Tool Location", diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index d59243eb75..d4394dfefa 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -33,8 +33,8 @@ smallvec = "1" siphasher = "1" weezl = "0.2" serde = { workspace = true } -# The XMP packet that File Info is read from (file_info.rs). -craft-xml = { workspace = true } +# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses. +roxmltree = "0.21" # PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm. aes = "0.8" md-5 = { version = "0.10", default-features = false } diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 908212aabe..1cb0a45273 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -5,7 +5,7 @@ use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{DateTime, Dict, Stream}; -use craft_xml::{Canonical, Doctype, El, Limits, Names, Options}; +use roxmltree::Node; use crate::import_scan::text_string; @@ -15,14 +15,12 @@ const MAX_XMP: usize = 8 << 20; const MAX_NESTING: usize = 256; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const XML: &str = "http://www.w3.org/XML/1998/namespace"; pub(crate) const DC: &str = "http://purl.org/dc/elements/1.1/"; const XMP: &str = "http://ns.adobe.com/xap/1.0/"; // brand-ok: the XMP namespace the files use pub(crate) const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace the files use -const XMP_NAMESPACES: Canonical<'static> = - Canonical::new(&[("rdf", RDF), ("dc", DC), ("xmp", XMP), ("xmpRights", XMP_RIGHTS), ("pdf", PDF)]); - /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { @@ -75,39 +73,39 @@ fn packet(pdf: &Pdf) -> Option> { } /// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16, or when its -/// [`xmp`] of `packet`; `None` when it isn't XML in UTF-8 or UTF-16, or when its elements nest -/// more than [`MAX_NESTING`] deep. +/// elements nest more than [`MAX_NESTING`] deep. fn xmp(packet: &[u8]) -> Option { let text = xml_text(packet)?; - let opts = Options { - names: Names::Canonical(&XMP_NAMESPACES), - doctype: Doctype::Reject, - limits: Limits { max_depth: MAX_NESTING, ..Limits::DEFAULT }, - ..Options::default() + // roxmltree parses nested elements recursively. + if nesting(&text) > MAX_NESTING { + return None; } - .strict(); - let root = opts.parse(text.as_bytes()).ok()?; + let xml = roxmltree::Document::parse(&text).ok()?; // The resource's own descriptions: those right under `rdf:RDF`, not the ones inside a value. - let descriptions: Vec<&El> = - std::iter::once(&root).chain(root.descendants()).filter(|n| n.is("rdf:RDF")).flat_map(|rdf| rdf.children("rdf:Description")).collect(); - let get = |name: &str| property(&descriptions, name); - let first = |name: &str| get(name).into_iter().next(); - let creators = get("dc:creator"); - let subjects = get("dc:subject"); + let descriptions: Vec> = xml + .descendants() + .filter(|n| n.has_tag_name((RDF, "RDF"))) + .flat_map(|rdf| rdf.children()) + .filter(|n| n.has_tag_name((RDF, "Description"))) + .collect(); + let get = |ns: &str, name: &str| property(&descriptions, ns, name); + let first = |ns: &str, name: &str| get(ns, name).into_iter().next(); + let creators = get(DC, "creator"); + let subjects = get(DC, "subject"); Some(FileInfo { - title: first("dc:title"), + title: first(DC, "title"), author: (!creators.is_empty()).then(|| creators.join(", ")), - description: first("dc:description"), - keywords: if subjects.is_empty() { first("pdf:Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, - copyright_notice: first("dc:rights"), - copyright_url: first("xmpRights:WebStatement"), - copyrighted: first("xmpRights:Marked").and_then(|m| match m.to_ascii_lowercase().as_str() { + description: first(DC, "description"), + keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, + copyright_notice: first(DC, "rights"), + copyright_url: first(XMP_RIGHTS, "WebStatement"), + copyrighted: first(XMP_RIGHTS, "Marked").and_then(|m| match m.to_ascii_lowercase().as_str() { "true" => Some(true), "false" => Some(false), _ => None, }), - created: first("xmp:CreateDate").and_then(|d| xmp_date(&d)), - modified: first("xmp:ModifyDate").and_then(|d| xmp_date(&d)), + created: first(XMP, "CreateDate").and_then(|d| xmp_date(&d)), + modified: first(XMP, "ModifyDate").and_then(|d| xmp_date(&d)), }) } @@ -125,14 +123,58 @@ fn xml_text(b: &[u8]) -> Option { } } -/// The values of property `name` on the first of `descriptions` that gives it: an attribute +/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, +/// CDATA sections, processing instructions, declarations and quoted attribute values. +fn nesting(xml: &str) -> usize { + let b = xml.as_bytes(); + let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); + // The index just past the first `end` at or after `from` (the end of the text if none). + let past = |from: usize, end: &[u8]| { + b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) + }; + while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { + let start = i + at; + let rest = b.get(start..).unwrap_or_default(); + if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else { + // A start tag: up to its `>` outside quotes; `/>` closes it at once. + let mut j = start + 1; + let mut quote = None; + while let Some(&c) = b.get(j) { + match (quote, c) { + (None, b'"' | b'\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (None, b'>') => break, + _ => {} + } + j += 1; + } + if b.get(j.saturating_sub(1)) != Some(&b'/') { + depth += 1; + most = most.max(depth); + } + i = j + 1; + } + } + most +} + +/// The values of property `ns` `name` on the first of `descriptions` that gives it: an attribute /// of the `rdf:Description`, or a property element's items ([`items`]), blank ones left out. -fn property(descriptions: &[&El], name: &str) -> Vec { +fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec { for d in descriptions { - if let Some(v) = d.attr(name).map(str::trim).filter(|v| !v.is_empty()) { + if let Some(v) = d.attribute((ns, name)).map(str::trim).filter(|v| !v.is_empty()) { return vec![v.to_string()]; } - let found = d.children(name).map(items).find(|v| !v.is_empty()); + let found = d.children().filter(|c| c.has_tag_name((ns, name))).map(items).find(|v| !v.is_empty()); if let Some(v) = found { return v; } @@ -143,12 +185,13 @@ fn property(descriptions: &[&El], name: &str) -> Vec { /// The items of property element `p`: the `rdf:li` of its `rdf:Seq`, `rdf:Bag` or `rdf:Alt` (the /// `x-default` language item first), else its own value ([`value_of`]); trimmed, blank ones left /// out. -fn items(p: &El) -> Vec { - let array = p.elements().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")); +fn items(p: Node<'_, '_>) -> Vec { + let array = p.children().find(|c| ["Seq", "Bag", "Alt"].into_iter().any(|t| c.has_tag_name((RDF, t)))); let mut values: Vec<(bool, String)> = match array { Some(a) => a - .children("rdf:li") - .map(|li| (li.attr("xml:lang").is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li))) + .children() + .filter(|c| c.has_tag_name((RDF, "li"))) + .map(|li| (li.attribute((XML, "lang")).is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li))) .collect(), None => vec![(false, value_of(p))], }; @@ -160,19 +203,19 @@ fn items(p: &El) -> Vec { /// The value of element `n`: the URI of its `rdf:resource` attribute (how RDF writes a URI value); /// for a qualified value, its `rdf:value` (an attribute or an element, on `n` or on an /// `rdf:Description` inside it), without the qualifiers; else the text inside it. -fn value_of(mut n: &El) -> String { +fn value_of(mut n: Node<'_, '_>) -> String { // Each step goes to an element inside `n`, so the loop ends. loop { - if let Some(uri) = n.attr("rdf:resource") { + if let Some(uri) = n.attribute((RDF, "resource")) { return uri.to_string(); } - let holder = n.child("rdf:Description").unwrap_or(n); - if let Some(v) = holder.attr("rdf:value") { + let holder = n.children().find(|c| c.has_tag_name((RDF, "Description"))).unwrap_or(n); + if let Some(v) = holder.attribute((RDF, "value")) { return v.to_string(); } - match holder.child("rdf:value") { + match holder.children().find(|c| c.has_tag_name((RDF, "value"))) { Some(v) => n = v, - None => return n.deep_text(), + None => return n.descendants().filter(|d| d.is_text()).filter_map(|d| d.text()).collect(), } } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index ecdaa60273..4bd499b9d4 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,12 +19,13 @@ vello_cpu = { workspace = true } # Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2. vello_common = { version = "0.2", default-features = false } image = { workspace = true } -craft-image-codecs = { workspace = true } flate2 = { workspace = true } jpeg-encoder = { workspace = true } gif = { workspace = true } # TIFF LZW compression (MIT OR Apache-2.0; already used by the image crate's TIFF reader). weezl = "0.2" +# PNG rows read one at a time (MIT OR Apache-2.0; already used by the image crate's PNG reader). +png = "0.18" log = { workspace = true } # Combining marks inside a lam-alef cluster (MIT OR Apache-2.0; already used by vectorcraft-text). unicode-general-category = "1" @@ -46,7 +47,8 @@ vectorcraft-plugins = { workspace = true } wat = { workspace = true } # Decoders the export tests read their files back with. image = { workspace = true, features = ["tga"] } -craft-tiff = { workspace = true } -craft-psd = { workspace = true } +tiff = "0.11" +# Reads the PSD exports back (MIT OR Apache-2.0). +psd = "0.3" proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index 0a8227b75f..a910480641 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -86,10 +86,8 @@ fn gray_and_bilevel_images_store_gray() { assert_eq!(gray(&file), levels); let (mime, file) = img.to_bilevel(150.0).unwrap(); assert_eq!(mime, "image/tiff"); - let tiff = craft_tiff::Tiff::parse(&file).unwrap(); - let ifd = &tiff.ifds[0]; - assert_eq!(ifd.u16(craft_tiff::tags::SAMPLES_PER_PIXEL), Some(1)); - assert_eq!(ifd.value(craft_tiff::tags::BITS_PER_SAMPLE).unwrap().to_u64_vec(), vec![1]); + let mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap(); + assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1)); let bw: Vec = [0u8, 0, 255, 255, 255, 0].iter().flat_map(|&g| [g, 255]).collect(); assert_eq!(gray(&file), bw, "from 128 up is white"); // Half-covered white over the light grey; a clear pixel is white. diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index 7655fa16e7..a488cceff2 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -146,7 +146,7 @@ fn unpack(mut src: &[u8], out: &mut Vec) { /// The composite as a reader sees it. fn merged(f: &[u8]) -> Vec { - craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes").composite_rgba8().expect("composite").data + ::psd::Psd::from_bytes(f).expect("a PSD a reader takes").rgba() } fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] { @@ -168,12 +168,11 @@ fn layers_are_the_top_level_layers_and_the_merged_image_is_the_render() { assert_eq!(p.records[1].rect, [0, 0, 50, 60], "the blue square and the corner"); // A reader decodes it: the layers, in place, and the merged image. - let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); - assert_eq!((psd.header.width, psd.header.height, psd.layers().len()), (100, 60, 2)); - let layer = psd.iter_layers().find(|l| l.name() == "Layer 1").unwrap(); - assert_eq!(layer.record.rect, craft_psd::layer::Rect { top: 10, left: 10, bottom: 40, right: 40 }); - let red = layer.rgba8().unwrap().data; - assert_eq!(pixel(&red, 30, 10, 10), [255, 0, 0, 255]); + let psd = ::psd::Psd::from_bytes(&file).unwrap(); + assert_eq!((psd.width(), psd.height(), psd.layers().len()), (100, 60, 2)); + let red = psd.layer_by_name("Layer 1").unwrap().rgba(); + assert_eq!(pixel(&red, 100, 20, 20), [255, 0, 0, 255]); + assert_eq!(pixel(&red, 100, 50, 45)[3], 0, "the blue square isn't on Layer 1"); // The merged image is the flat render: colours on white where transparent, alpha apart. let img = Renderer::new().render_region_with(&doc, doc.artboards[0].rect, 1.0, &opts(PsdOptions::default()).render_options(RasterFormat::Psd)); @@ -201,7 +200,7 @@ fn flat_writes_the_merged_image_alone_on_white() { assert_eq!(o.encode(&img, RasterFormat::Psd).unwrap(), file); let file = export(&doc, &RasterExportOptions { ppi: 150.0, ..o }); assert_eq!(u32_at(parse(&file).resources, 12), 150 << 16); - assert_eq!(craft_psd::PsdFile::from_bytes(&file).unwrap().header.width, 208); + assert_eq!(::psd::Psd::from_bytes(&file).unwrap().width(), 208); } #[test] @@ -231,10 +230,8 @@ fn maximum_editability_writes_groups_and_an_object_per_layer() { assert_eq!((p.records[4].opacity, p.records[4].blend, p.records[4].rect), (128, *b"mul ", [20, 30, 50, 60])); assert_eq!((p.records[8].blend, p.records[8].opacity), (*b"pass", 255)); assert_eq!(p.records[6].rect, [0, 0, 6, 6]); - let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); - let content_layers = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::layer::SectionType::Other)).count(); - let groups = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::layer::SectionType::OpenFolder | craft_psd::layer::SectionType::ClosedFolder)).count(); - assert_eq!((content_layers, groups), (3, 3)); + let psd = ::psd::Psd::from_bytes(&file).unwrap(); + assert_eq!((psd.layers().len(), psd.groups().len()), (3, 3)); assert_eq!(merged(&file), merged(&export(&doc, &opts(PsdOptions::default()))), "the same merged image"); // Hidden layers: written hidden, drawn as if visible, instead of left out. @@ -303,8 +300,8 @@ fn a_background_colour_is_the_bottom_layer() { let p = parse(&file); assert_eq!((p.channels, p.count), (3, 3), "opaque: no merged alpha"); assert_eq!((p.records[0].name.as_str(), p.records[0].rect), ("Background", [0, 0, 60, 100])); - let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); - assert!(psd.iter_layers().find(|l| l.name() == "Background").unwrap().rgba8().unwrap().data.chunks(4).all(|p| p == [0, 0, 255, 255])); + let psd = ::psd::Psd::from_bytes(&file).unwrap(); + assert!(psd.layer_by_name("Background").unwrap().rgba().chunks(4).all(|p| p == [0, 0, 255, 255])); assert_eq!(pixel(&merged(&file), 100, 5, 50), [0, 0, 255, 255]); } diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs index 380b6d155c..c70eacd2fc 100644 --- a/crates/render/src/encode/tests_tiff.rs +++ b/crates/render/src/encode/tests_tiff.rs @@ -1,25 +1,22 @@ //! TIFF export: byte order, LZW, colour models and samples, resolution, profile, strips. -use craft_tiff::Tiff; -use craft_tiff::tags; +use std::io::Cursor; + +use tiff::decoder::{Decoder, DecodingResult}; +use tiff::tags::Tag; use super::jpeg::ColorModel; use super::tiff::{self as t, ByteOrder, Compression, Image, Layout, Photometric, TiffOptions}; -fn ifd0(file: &[u8]) -> craft_tiff::reader::Ifd { - Tiff::parse(file).expect("a TIFF").ifds.into_iter().next().expect("IFD0") -} - -fn tag_u16_vec(ifd: &craft_tiff::reader::Ifd, tag: u16) -> Option> { - ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u16).collect()) -} - -fn tag_u32_vec(ifd: &craft_tiff::reader::Ifd, tag: u16) -> Option> { - ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u32).collect()) +fn decoder(file: &[u8]) -> Decoder> { + Decoder::new(Cursor::new(file)).expect("a TIFF") } fn pixels(file: &[u8]) -> Vec { - craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, file).expect("decodes").data + match decoder(file).read_image().expect("decodes") { + DecodingResult::U8(v) => v, + _ => panic!("8-bit samples"), + } } /// Straight RGBA: flat areas (as art is) with a transparent corner when `clear`. @@ -49,7 +46,7 @@ fn byte_order_writes_an_ii_or_mm_header() { assert_eq!(&file[..4], magic); let rgb: Vec = px.chunks(4).flat_map(|p| &p[..3]).copied().collect(); assert_eq!(pixels(&file), rgb, "{order:?}"); - assert_eq!(ifd0(&file).string(tags::SOFTWARE).as_deref(), Some("VectorCraft")); + assert_eq!(decoder(&file).get_tag_ascii_string(Tag::Software).unwrap(), "VectorCraft"); } for (s, o) in [("little", ByteOrder::Little), ("MM", ByteOrder::Big), ("mac", ByteOrder::Big), ("ii", ByteOrder::Little), ("big", ByteOrder::Big)] { @@ -64,51 +61,44 @@ fn lzw_is_smaller_and_decodes_the_same() { let plain = t::encode_samples(&px, 120, 90, 72.0, &TiffOptions { lzw: false, ..opts(ColorModel::Rgb) }).unwrap(); let lzw = t::encode_samples(&px, 120, 90, 72.0, &opts(ColorModel::Rgb)).unwrap(); assert!(lzw.len() * 4 < plain.len(), "{} vs {}", lzw.len(), plain.len()); - assert_eq!(ifd0(&lzw).u16(tags::COMPRESSION), Some(5)); - assert_eq!(ifd0(&plain).u16(tags::COMPRESSION), Some(1)); + assert_eq!(decoder(&lzw).get_tag_unsigned::(Tag::Compression).unwrap(), 5); + assert_eq!(decoder(&plain).get_tag_unsigned::(Tag::Compression).unwrap(), 1); assert_eq!(pixels(&lzw), px, "transparency kept as an alpha channel"); assert_eq!(pixels(&plain), px); - let img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &lzw).unwrap(); - assert_eq!(img.data, px); + // The image crate reads it too. + let img = image::load_from_memory_with_format(&lzw, image::ImageFormat::Tiff).unwrap().to_rgba8(); + assert_eq!(img.into_raw(), px); } #[test] fn colour_models_write_their_samples() { let px = art(9, 6, true); let rgba = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); - let rgba_ifd = ifd0(&rgba); - assert_eq!(rgba_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(2)); - assert_eq!(rgba_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(4)); - assert_eq!(tag_u16_vec(&rgba_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8, 8, 8, 8].as_slice())); - assert_eq!(tag_u16_vec(&rgba_ifd, tags::EXTRA_SAMPLES).as_deref(), Some([2].as_slice()), "unassociated alpha"); + assert_eq!(decoder(&rgba).colortype().unwrap(), tiff::ColorType::RGBA(8)); + assert_eq!(decoder(&rgba).get_tag_u16_vec(Tag::ExtraSamples).unwrap(), [2], "unassociated alpha"); let opaque = art(9, 6, false); let rgb = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); - let rgb_ifd = ifd0(&rgb); - assert_eq!(rgb_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(2)); - assert_eq!(rgb_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(3)); - assert!(rgb_ifd.value(tags::EXTRA_SAMPLES).is_none()); + assert_eq!(decoder(&rgb).colortype().unwrap(), tiff::ColorType::RGB(8)); + assert!(decoder(&rgb).find_tag(Tag::ExtraSamples).unwrap().is_none()); let gray = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - let gray_ifd = ifd0(&gray); - assert_eq!(gray_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1)); - assert_eq!(tag_u16_vec(&gray_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8].as_slice())); - assert_eq!(gray_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(1), "black is zero"); + assert_eq!(decoder(&gray).colortype().unwrap(), tiff::ColorType::Gray(8)); + assert_eq!(decoder(&gray).get_tag_unsigned::(Tag::PhotometricInterpretation).unwrap(), 1, "black is zero"); let g = pixels(&gray); assert_eq!((g[0], g[8]), (super::jpeg::to_gray()([200, 30, 40]), 255), "lightness; white stays white"); let on_white = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - let on_white_ifd = ifd0(&on_white); - assert_eq!(on_white_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1), "grey has no alpha: few readers take it"); + assert_eq!(decoder(&on_white).colortype().unwrap(), tiff::ColorType::Gray(8), "grey has no alpha: few readers take it"); assert_eq!(pixels(&on_white)[0], 255, "the transparent corner is white"); // CMYK: four samples a pixel, ink amounts as given. let inks: Vec = (0..9 * 6 * 4).map(|i| (i * 7 % 256) as u8).collect(); let cmyk = t::encode_samples(&inks, 9, 6, 72.0, &opts(ColorModel::Cmyk)).unwrap(); - let d = ifd0(&cmyk); - assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(4)); - assert_eq!(d.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(5), "separated"); - assert_eq!(d.u16(332), Some(1), "CMYK inks"); - let cmyk_img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &cmyk).unwrap(); - assert_eq!(cmyk_img.data, inks); + let mut d = decoder(&cmyk); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::CMYK(8)); + assert_eq!(d.get_tag_unsigned::(Tag::SamplesPerPixel).unwrap(), 4); + assert_eq!(d.get_tag_unsigned::(Tag::PhotometricInterpretation).unwrap(), 5, "separated"); + assert_eq!(d.get_tag_unsigned::(Tag::Unknown(332)).unwrap(), 1, "CMYK inks"); + assert_eq!(pixels(&cmyk), inks); } #[test] @@ -116,17 +106,16 @@ fn resolution_tags_and_the_profile() { let px = art(4, 4, false); for (ppi, rational) in [(300.0, (300, 1)), (72.5, (7250, 100))] { let file = t::encode_samples(&px, 4, 4, ppi, &opts(ColorModel::Rgb)).unwrap(); - let d = ifd0(&file); - for tag in [tags::X_RESOLUTION, tags::Y_RESOLUTION] { - let Some(craft_tiff::reader::IfdValue::Rationals(v)) = d.value(tag) else { panic!("rational") }; - assert_eq!(v.as_slice(), &[rational]); + let mut d = decoder(&file); + for tag in [Tag::XResolution, Tag::YResolution] { + assert_eq!(d.get_tag(tag).unwrap(), tiff::decoder::ifd::Value::Rational(rational.0, rational.1)); } - assert_eq!(d.u16(tags::RESOLUTION_UNIT), Some(2), "inches"); + assert_eq!(d.get_tag_unsigned::(Tag::ResolutionUnit).unwrap(), 2, "inches"); } for model in ColorModel::ALL { let src = if model == ColorModel::Cmyk { vec![0; 64] } else { px.clone() }; let file = t::encode_samples(&src, 4, 4, 72.0, &TiffOptions { embed_icc: true, ..opts(model) }).unwrap(); - let icc = ifd0(&file).bytes(tags::ICC_PROFILE).unwrap(); + let icc = decoder(&file).get_tag_u8_vec(Tag::IccProfile).unwrap(); assert_eq!(&icc[36..40], b"acsp", "{model:?}: an ICC profile"); let space: &[u8; 4] = match model { ColorModel::Rgb => b"RGB ", @@ -136,7 +125,7 @@ fn resolution_tags_and_the_profile() { assert_eq!(&icc[16..20], space, "{model:?}"); } let file = t::encode_samples(&px, 4, 4, 72.0, &opts(ColorModel::Rgb)).unwrap(); - assert!(ifd0(&file).value(tags::ICC_PROFILE).is_none()); + assert!(decoder(&file).find_tag(Tag::IccProfile).unwrap().is_none()); } #[test] @@ -146,10 +135,10 @@ fn big_images_take_several_strips_in_either_order() { for order in ByteOrder::ALL { for lzw in [false, true] { let file = t::encode_samples(&px, w, h, 72.0, &TiffOptions { lzw, byte_order: order, ..opts(ColorModel::Gray) }).unwrap(); - let d = ifd0(&file); - let offsets = tag_u32_vec(&d, tags::STRIP_OFFSETS).unwrap(); + let mut d = decoder(&file); + let offsets = d.get_tag_u32_vec(Tag::StripOffsets).unwrap(); // 300 bytes a row: 27 rows a strip. - assert_eq!(d.u32(tags::ROWS_PER_STRIP), Some(27)); + assert_eq!(d.get_tag_u32(Tag::RowsPerStrip).unwrap(), 27); assert_eq!(offsets.len(), 4); assert!(offsets.iter().all(|o| o % 2 == 0), "strips start on a word"); assert_eq!(pixels(&file).len(), (w * h) as usize, "{order:?} lzw {lzw}"); @@ -163,14 +152,11 @@ fn packbits_and_bilevel_images_decode() { let rows = [0b1010_1010u8, 0b1100_0000, 0xFF, 0xC0, 0, 0]; let image = Image { width: 10, height: 3, data: &rows, photometric: Photometric::WhiteIsZero, samples: 1, bits: 1, alpha: false }; let file = t::write(&image, &Layout { byte_order: ByteOrder::Big, compression: Compression::PackBits, ppi: 72.0, icc: None }).unwrap(); - let d = ifd0(&file); - assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(1)); - assert_eq!(tag_u16_vec(&d, tags::BITS_PER_SAMPLE).as_deref(), Some([1].as_slice())); - assert_eq!(d.u16(tags::COMPRESSION), Some(32773)); + let mut d = decoder(&file); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::Gray(1)); + assert_eq!(d.get_tag_unsigned::(Tag::Compression).unwrap(), 32773); // The decoder turns WhiteIsZero into black-is-zero. - let dec = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &file).unwrap(); - assert_eq!(dec.width, 10); - assert_eq!(dec.height, 3); + assert_eq!(pixels(&file), rows.map(|b| !b)); // A buffer that doesn't match the size is refused, not written. let short = Image { data: &rows[..5], ..image }; assert!(t::write(&short, &Layout { byte_order: ByteOrder::Little, compression: Compression::None, ppi: 72.0, icc: None }).is_err()); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 65ce8a91fd..c79260e1c0 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -67,6 +67,13 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { if let Some(art) = painted_art(symbols, n) { return art.visual_bounds_with(symbols).map(|r| with_raster_reach(symbols, n, r)); } + // Copies that each carry the raster effects listed before their Transform effect reach as far + // as each copy's raster effects paint, and the raster effects after it spread them all. + if effects::outer_effects(n).len() < n.appearance.effects.len() + && let Some(art) = effects::reshape(Some(symbols.document()), n, None) + { + return cull_bounds(symbols, &art); + } let bp = node_bezpath(n)?; let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) }; let (g, carry) = effected_path_carrying(symbols.document(), n, &bp); diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 190d433973..65a886dc8c 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -423,11 +423,12 @@ impl Renderer { let outer = self.made_again; self.made_again = outer || !kept; // With Crop Marks, the object inside the art paints its own raster effects; Vector - // Halftone samples them with the object. + // Halftone samples them with the object. The copies of a Transform effect that carry the + // raster effects listed before it paint those themselves. let rfx = if effects::has_crop_marks(a) || effects::addons::halftone::has_halftone(a) { vec![] } else { - effects::raster_effects(&a.appearance.effects) + effects::raster_effects(effects::outer_effects(a)) }; match cull_bounds(f.symbols, &art) { Some(reach) if !f.opts.outline && !rfx.is_empty() => { diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index e51db9b251..c130fbe346 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -258,13 +258,9 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option { return None; } let to = fitted_dims(w, h, allowance)?; - // A PNG that isn't interlaced or animated (and upright in pre-pixel EXIF) is read a row at a time when the reader's buffers fit the allowance. + // A PNG that isn't interlaced is read a row at a time when the reader's buffers fit the allowance. let row_scratch = if format == image::ImageFormat::Png { - craft_image_codecs::PngDecodeRows::new_streaming(bytes, &craft_image_codecs::Limits::default()) - .ok() - .filter(|dec| dec.streamable()) - .and_then(|dec| png_row_scratch(&dec.header())) - .filter(|&s| s <= allowance) + png_reader(bytes).filter(|r| !r.info().interlaced).and_then(|r| png_row_scratch(&r)).filter(|&s| s <= allowance) } else { None }; @@ -287,18 +283,17 @@ const PNG_KEPT_ROWS: u64 = 128 << 10; /// stream buffer grows by. const PNG_WINDOW_AND_STEP: u64 = (32 << 10) + (8 << 10); -/// The most memory `craft-image-codecs`'s PNG row decoder takes in its buffers to read its image -/// a row at a time: the stream buffer holds the rows already read up to [`PNG_KEPT_ROWS`] or four -/// rows, whichever is more, the previous and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer -/// grows by doubling its reservation, and while it grows its old reservation and the new one, up -/// to twice the size it holds, are both allocated: three times that size at most. A copy of a -/// stored row and the row given out come on top. `None` when a row given out doesn't fit memory. -fn png_row_scratch(hdr: &craft_image_codecs::PngHeader) -> Option { - let out = u64::from(hdr.width) - .checked_mul(u64::from(hdr.layout.channels()))? - .checked_mul(u64::from(hdr.sample.bytes()))?; - // A filter byte and the row's samples. - let stored = 1u64.checked_add(out)?; +/// The most memory `r` takes in its buffers to read its image a row at a time: the stream buffer +/// holds the rows already read up to [`PNG_KEPT_ROWS`] or four rows, whichever is more, the previous +/// and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer grows by doubling its reservation, +/// and while it grows its old reservation and the new one, up to twice the size it holds, are both +/// allocated: three times that size at most. A copy of a stored row and the row given out come on +/// top. `None` when a row given out doesn't fit memory. +fn png_row_scratch(r: &png::Reader>) -> Option { + let info = r.info(); + // A filter byte and the row's samples, in whole bytes. + let stored = 1 + (u64::from(info.width) * info.color_type.samples() as u64 * info.bit_depth as u64).div_ceil(8); + let out = u64::try_from(r.output_line_size(info.width)?).ok()?; let kept = (stored * 4).next_multiple_of(64).max(PNG_KEPT_ROWS); let stream = kept + stored * 2 + PNG_WINDOW_AND_STEP; Some(stream * 3 + stored + out) @@ -318,6 +313,14 @@ fn fitted_dims(w: u32, h: u32, allowance: u64) -> Option<(u32, u32)> { (u64::from(nw) * u64::from(nh) * 4 <= allowance).then_some((nw, nh)) } +/// A PNG reader of `bytes` that gives 8-bit gray, gray with alpha, RGB or RGBA rows, with its +/// header and the chunks before the pixels read. +fn png_reader(bytes: &[u8]) -> Option>> { + let mut d = png::Decoder::new(std::io::Cursor::new(bytes)); + d.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + d.read_info().ok() +} + /// `bytes` decoded at the size [`reduction`] gives. Each pixel of the result is the mean of the /// image's pixels it covers. [`Undecoded::Unreadable`] when they can't be read, and /// [`Undecoded::NoRoom`] when the result and what reading takes ([`Reduction::scratch`]) don't fit @@ -334,58 +337,18 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result Option { let mut shrink = Shrink::new(r.size, r.to); if r.rows { - use craft_image_codecs::{ChannelLayout, CodecError, Format, Limits, PngDecodeRows, SampleType}; - let mut dec = PngDecodeRows::new_streaming(bytes, &Limits::default()).ok()?; - if !dec.streamable() { - return None; - } - let hdr = dec.header(); - dec.step_stream::(1, &mut || true, &mut |_y, row| { - match (hdr.layout, hdr.sample) { - (ChannelLayout::Gray, SampleType::U8) => shrink.row(row.iter().map(|&g| [g, g, g, 255])), - (ChannelLayout::GrayA, SampleType::U8) => { - shrink.row(row.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])) - } - (ChannelLayout::Rgb, SampleType::U8) => { - shrink.row(row.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])) - } - (ChannelLayout::Rgba, SampleType::U8) => shrink.row(row.as_chunks::<4>().0.iter().copied()), - (ChannelLayout::Gray, SampleType::U16) => shrink.row(row.as_chunks::<2>().0.iter().map(|&g| { - let v = (u16::from_ne_bytes(g) >> 8) as u8; - [v, v, v, 255] - })), - (ChannelLayout::GrayA, SampleType::U16) => { - shrink.row(row.as_chunks::<4>().0.iter().map(|&[g0, g1, a0, a1]| { - let g = (u16::from_ne_bytes([g0, g1]) >> 8) as u8; - let a = (u16::from_ne_bytes([a0, a1]) >> 8) as u8; - [g, g, g, a] - })) - } - (ChannelLayout::Rgb, SampleType::U16) => { - shrink.row(row.as_chunks::<6>().0.iter().map(|&[r0, r1, g0, g1, b0, b1]| { - [ - (u16::from_ne_bytes([r0, r1]) >> 8) as u8, - (u16::from_ne_bytes([g0, g1]) >> 8) as u8, - (u16::from_ne_bytes([b0, b1]) >> 8) as u8, - 255, - ] - })) - } - (ChannelLayout::Rgba, SampleType::U16) => { - shrink.row(row.as_chunks::<8>().0.iter().map(|&[r0, r1, g0, g1, b0, b1, a0, a1]| { - [ - (u16::from_ne_bytes([r0, r1]) >> 8) as u8, - (u16::from_ne_bytes([g0, g1]) >> 8) as u8, - (u16::from_ne_bytes([b0, b1]) >> 8) as u8, - (u16::from_ne_bytes([a0, a1]) >> 8) as u8, - ] - })) - } - _ => return Err(CodecError::unsupported(Format::Png, "unsupported PNG channel layout")), + let mut reader = png_reader(bytes)?; + let (color, _) = reader.output_color_type(); + while let Some(row) = reader.next_row().ok()? { + let data = row.data(); + match color { + png::ColorType::Grayscale => shrink.row(data.iter().map(|&g| [g, g, g, 255])), + png::ColorType::GrayscaleAlpha => shrink.row(data.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])), + png::ColorType::Rgb => shrink.row(data.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])), + png::ColorType::Rgba => shrink.row(data.as_chunks::<4>().0.iter().copied()), + png::ColorType::Indexed => return None, } - Ok(()) - }) - .ok()??; + } } else { let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index 6daf192fc4..7f9453fda5 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -425,8 +425,9 @@ impl Renderer { if !k.is_finite() || k <= 1e-12 { return; } - // The art in its own coordinates, with its own resources (its composite fonts included). - let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&e.doc.composite_fonts); + // The art in its own coordinates, with its own resources (its composite fonts included, and + // none of the host document's). + let _lent = vectorcraft_text::FontDb::global().lend_document_composite_fonts(&e.doc.composite_fonts); let symbols = e.doc.symbol_boxes().quick(); let visible = m.inverse().transform_rect_bbox(f.visible); let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f }; diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 15389cd805..27e1e7e1e9 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -209,7 +209,7 @@ fn a_documents_own_composite_font_draws_without_being_registered() { } /// A placed document draws its type in its own definition of a composite font that the document -/// placing it defines differently. +/// placing it defines differently, and as missing in one only the document placing it defines. #[test] fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts, ImageBlob, LinkInfo, PlacedDocument}; @@ -260,6 +260,55 @@ fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { assert!(own == page(&plain, plain_id, "fontchange-placed-twice-plain", vec![]), "drawn in the placed document's definition"); assert!(own != page(&inter, inter_id, "fontchange-placed-twice-inter", vec![]), "not in the page's"); assert!(vectorcraft_text::FontDb::global().composite_font(NAME).is_none()); + // A placed document that defines no composite font draws type naming the page's as missing. + let (undefined, undefined_id) = typed(NAME); + let (missing, missing_id) = typed("CF Placed Nowhere"); + let borrowed = page(&undefined, undefined_id, "fontchange-placed-undefined", vec![definition("Inter")]); + assert!(borrowed == page(&missing, missing_id, "fontchange-placed-missing", vec![]), "drawn as in a missing font"); + assert!(borrowed != page(&inter, inter_id, "fontchange-placed-undefined-inter", vec![]), "not in the page's definition"); +} + +/// A document that defines no composite font, drawn while another document's composite font of +/// the name its type is set in is registered (that document in front), draws that type as in a +/// missing font: in a render, an ink render, a thumbnail and a node render. +#[test] +fn a_document_draws_type_in_another_documents_composite_font_as_missing() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts}; + let _adding = adding_fonts(); + const NAME: &str = "CF Front Only"; + let db = vectorcraft_text::FontDb::global(); + let typed = |family: &str| { + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style)))); + let l = d.layers[0].id; + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) + }; + let (back, id) = typed(NAME); + let (missing, missing_id) = typed("CF Front Nowhere"); + let (plain, plain_id) = typed("JetBrains Mono"); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let region = Rect::new(0.0, 0.0, 300.0, 150.0); + let draw = |d: &Document, id: NodeId| { + let n = d.node(id).unwrap().clone(); + ( + Renderer::new().render(d, 300, 150, Affine::IDENTITY, &opts).pixels, + Renderer::new().render_region_inks(d, region, 1.0, &opts), + Renderer::new().render_thumbnail(d, id, 64).map(|r| r.pixels), + Renderer::new().render_node(d, &std::sync::Arc::new(n), 300, 150, Affine::IDENTITY).map(|r| r.pixels), + ) + }; + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new("JetBrains Mono", "Regular"); + db.set_composite_font(&CompositeFont { name: NAME.into(), parts }); + let (drawn, as_missing, in_front) = (draw(&back, id), draw(&missing, missing_id), draw(&plain, plain_id)); + db.remove_composite_font(NAME); + assert!(drawn.0 != in_front.0, "not in the front document's definition"); + assert!(drawn.0 == as_missing.0, "rendered"); + assert!(drawn.1 == as_missing.1, "rendered as ink amounts"); + assert!(drawn.2 == as_missing.2, "thumbnail"); + assert!(drawn.3 == as_missing.3, "node"); } /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index c359f65219..f93124e1b9 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -1219,3 +1219,26 @@ fn a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn() { assert!(within(b), "{what}: painted {b:?} for art {art:?}"); } } + +/// Each copy of a Transform effect draws the drop shadow listed before the effect under itself, +/// scaled with it: the red square (10..30) and its copy scaled 200 % and moved 60 pt down (0..40 +/// across, 60..100 down) each have a green shadow to their right, 20 pt for the square and 40 pt +/// for the copy. The bounds reach as far as the copy's shadow. +#[test] +fn each_copy_of_a_transform_effect_draws_its_own_shadow() { + let shadow = fx("stylize.dropShadow", json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"})); + let copies = fx("distort.transform", json!({"scaleH": 200, "scaleV": 200, "moveV": 60, "copies": 1})); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), red); + n.appearance.effects = vec![shadow, copies]; + let d = doc_with(Document::new(100.0, 100.0), n); + let img = render(&d); + assert_eq!(img.pixel(20, 20), [255, 0, 0, 255], "the square"); + assert_eq!(img.pixel(45, 20), [0, 255, 0, 255], "the square's shadow"); + assert_eq!(img.pixel(20, 80), [255, 0, 0, 255], "the copy"); + assert_eq!(img.pixel(70, 80), [0, 255, 0, 255], "the copy's shadow, 40 pt to its right"); + assert_eq!(img.pixel(85, 80), [255, 255, 255, 255], "past the copy's shadow"); + let n = &d.layers[0].children().unwrap()[0]; + let b = crate::fx::visual_bounds(&d.symbol_boxes(), n).unwrap(); + assert!(b.x1 >= 80.0 - 1e-9, "{b:?}"); +} diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index 16e98038fa..79a608788b 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -19,7 +19,7 @@ type Resolved = Option<(Arc, FontMatch)>; thread_local! { /// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the - /// latest loan last. + /// latest loan last. Only the latest loan of a database is looked in. static LENT: RefCell> = const { RefCell::new(Vec::new()) }; } @@ -34,8 +34,8 @@ struct Loan { number: u64, /// The fonts, by lowercased name. fonts: HashMap>, - /// The database's generation when they were lent, when each was then the font the database - /// holds by its name. + /// The database's generation when they were lent, when they were then exactly the fonts the + /// database holds: each the font it holds by its name, and none it holds left out. same_at: Option, /// A number for the definitions lent: loans of the same definitions have the same one. print: u64, @@ -67,8 +67,8 @@ pub struct CompositeCoverage { impl FontDb { /// Make `font` available by its name, in place of a composite font of that name (any case). /// The font menus and anything else that lists fonts change only when the definition does. - /// Whether it did. A font of that name a guard lends ([`FontDb::lend_composite_fonts`]) stays - /// in use on the guard's thread. + /// Whether it did. While a guard lends composite fonts ([`FontDb::lend_composite_fonts`]), the + /// guard's thread uses the fonts it lends and not this one. pub fn set_composite_font(&self, font: &CompositeFont) -> bool { let key = font.name.to_lowercase(); { @@ -91,15 +91,13 @@ impl FontDb { removed } - /// The composite font named `name` (any case): the one the latest guard of this thread that - /// lends that name lends ([`FontDb::lend_composite_fonts`]), else the one the database holds. + /// The composite font named `name` (any case). While a guard of this thread lends a document's + /// composite fonts ([`FontDb::lend_composite_fonts`]), the one the latest such guard lends, and + /// `None` when that document does not define the name, also when the database or an earlier + /// guard holds one of that name. Otherwise the one the database holds. pub fn composite_font(&self, name: &str) -> Option> { if self.lends() { - let key = name.to_lowercase(); - if let Some(f) = self.lent_font(&key) { - return Some(f); - } - return self.composites.read().unwrap_or_else(|e| e.into_inner()).get(&key).cloned(); + return self.lent_font(&name.to_lowercase()); } let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); if map.is_empty() { @@ -113,30 +111,27 @@ impl FontDb { LENT.try_with(|l| l.borrow().iter().any(|loan| loan.db == self.id)).unwrap_or(false) } - /// The composite font named `key` (lowercased) that the latest guard of this thread lending that - /// name lends. + /// The composite font named `key` (lowercased) that the latest guard of this thread lending + /// composite fonts of this database lends. fn lent_font(&self, key: &str) -> Option> { - LENT.try_with(|l| l.borrow().iter().rev().filter(|loan| loan.db == self.id).find_map(|loan| loan.fonts.get(key).cloned())).ok().flatten() + LENT.try_with(|l| l.borrow().iter().rev().find(|loan| loan.db == self.id).and_then(|loan| loan.fonts.get(key).cloned())).ok().flatten() } - /// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while each - /// font the guards of this thread lend is the one the database held by its name when it was lent - /// and the database has not changed since, else a number made from `g` and the definitions - /// lent, so what was laid out in one document's definitions is not taken for another's. + /// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while no + /// guard of this thread lends composite fonts of this database, or while the latest one lends + /// exactly the fonts the database held when it was lent and the database has not changed since, + /// else a number made from `g` and the definitions lent, so what was laid out in one document's + /// definitions is not taken for another's. pub(super) fn lent_generation(&self, g: u64) -> u64 { - LENT.try_with(|l| { - let l = l.borrow(); - let loans = l.iter().filter(|loan| loan.db == self.id); - if loans.clone().all(|loan| loan.same_at == Some(g)) { - return g; - } - let mut h = DefaultHasher::new(); - g.hash(&mut h); - for loan in loans { + LENT.try_with(|l| match l.borrow().iter().rev().find(|loan| loan.db == self.id) { + Some(loan) if loan.same_at != Some(g) => { + let mut h = DefaultHasher::new(); + g.hash(&mut h); loan.print.hash(&mut h); + // The database's generations count up from 0 and never reach the top bit. + h.finish() | 1 << 63 } - // The database's generations count up from 0 and never reach the top bit. - h.finish() | 1 << 63 + _ => g, }) .unwrap_or(g) } @@ -245,20 +240,41 @@ impl FontDb { } } - /// Make each of the composite fonts `fonts` (a document's) available on this thread while the - /// returned guard lives, in place of a composite font of the same name that the database holds - /// or that an earlier guard of this thread lends. An app's session registers the composite fonts - /// of its front document; with the guard, a document exported without a session, one that is not - /// in front, or one placed in another document draws its type in its own, also while another - /// thread draws another document that defines the same name differently, or the session - /// registers another document's. Of fonts that share a name (in any case), the first is lent. + /// Make the composite fonts `fonts` (a document's) the only ones available on this thread while + /// the returned guard lives: type naming a composite font they do not define is set as in a + /// missing font, also when the database or an earlier guard of this thread holds one of that + /// name. An app's session registers the composite fonts of its front document; with the guard, a + /// document exported without a session, one that is not in front, or one placed in another + /// document draws its type in its own, also while another thread draws another document that + /// defines the same name differently, or the session registers another document's. Of fonts + /// that share a name (in any case), the first is lent. When none of `fonts` is usable and no + /// guard of this thread lends composite fonts of the database, the guard lends none: type naming + /// any composite font is set as in a missing font while it lives. When none is usable inside + /// another guard (a temporary document drawn while a document draws), nothing is lent and that + /// guard's fonts stay in use; [`FontDb::lend_document_composite_fonts`] lends none there too. /// Other threads, and the fonts the database holds and lists, are left as they are. The guard - /// stays on its thread and ends its loan when it drops, also when the thread unwinds. + /// stays on its thread and ends its loan when it drops, also when the thread unwinds. With no + /// guard (type laid out on its own), the fonts the database holds are used. pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { + self.lend(fonts, false) + } + + /// [`FontDb::lend_composite_fonts`] for a document that is drawn or measured as itself, also + /// inside another document's guard (a document placed in another): when it defines no usable + /// composite font, type naming any composite font is set as in a missing font while the guard + /// lives. + pub fn lend_document_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { + self.lend(fonts, true) + } + + /// Lend the usable fonts of `fonts`. With none, lend an empty set when `always` or when no + /// guard of this thread lends fonts of this database (and the database holds some), else + /// nothing. + fn lend(&self, fonts: &[CompositeFont], always: bool) -> LentCompositeFonts<'_> { // A font a document can't use (its name an installed family's, or a value out of range) // is not lent: an app leaves it out when it opens the document. let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect(); - if usable.is_empty() { + if usable.is_empty() && !always && (self.lends() || self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty()) { return LentCompositeFonts { number: None, thread: PhantomData }; } let mut lent: HashMap> = HashMap::with_capacity(usable.len()); @@ -281,6 +297,8 @@ impl FontDb { }; lent.insert(key, font); } + // Each lent font is the database's; with as many, none it holds is left out. + same &= lent.len() == map.len(); self.generation.load(Ordering::Relaxed) }; let number = NEXT_LOAN.fetch_add(1, Ordering::Relaxed); diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 9e4cb10e36..d43e17ad99 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -243,11 +243,16 @@ pub(super) fn drawn_after_tab<'g>(sg: &'g [SGlyph], drawn: &'g [usize], k: usize /// them) set on a line whose content starts `x_start` from the tab origin: tabs of the text take /// what [`tab_advance`] gives them from where the glyphs drawn before them end, aligning the glyphs /// drawn after them, not the width of their glyph. The tab of a list marker (length 0) keeps its -/// own width. -pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { +/// own width. With `end_trim` (a glyph index and an amount), that glyph is set that much narrower +/// where it is drawn (a line's end trim), before the tabs drawn after it take their stops. +pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64, end_trim: Option<(usize, f64)>) -> f64 { let mut x = x_start; - for (k, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, g))) { - x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0 } else { g.adv }; + for (k, j, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, j, g))) { + x += if g.ch == '\t' && g.len > 0 { + tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0 + } else { + g.adv - end_trim.filter(|&(at, _)| at == j).map_or(0.0, |(_, trim)| trim) + }; } x - x_start } diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 0d4ec2834e..5cf7a87224 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -459,8 +459,9 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC .filter(|&j| j < trimmed) .collect(); let hyphen = if hyphenated { end.checked_sub(1).and_then(|k| glyphs.get(k)).map_or(0.0, |g| hyphen_glyph(g).adv) } else { 0.0 }; - let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0); - drawn_width(glyphs, &drawn, ¶.tabs, ind_l) + hyphen - credit <= width + EPS + // The last glyph's end trim comes off where it is drawn, before the tabs after it. + let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k).map(|&trim| (k, trim))); + drawn_width(glyphs, &drawn, ¶.tabs, ind_l, credit) + hyphen <= width + EPS }; let fits: Option<&dyn Fn(usize, bool) -> bool> = (!cx.vertical && bidi.is_some_and(|b| b.has_rtl())).then_some(&drawn_fits); let line = |ty| { @@ -710,9 +711,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]); // The line is as wide as it is drawn: its glyphs before its trailing spaces in drawing order, - // each tab up to its stop from where the glyphs drawn before it end. + // each tab up to its stop from where the glyphs drawn before it end, with the last glyph's end + // trim taken off where it is drawn. let measured: Vec = order.iter().map(|&offset| index + offset).filter(|&j| j < trimmed).collect(); - let measured_width = drawn_width(sg, &measured, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; + let trim_at = trimmed.checked_sub(1).filter(|&k| k >= index).map(|k| (k, end_trim)); + let measured_width = drawn_width(sg, &measured, ¶.tabs, position.ind_l, trim_at) + hyphen.as_ref().map_or(0.0, |g| g.adv); // The glyphs before `stop` in visual order, each with the advance it is set with. A list // marker does not hang: it is counted with no width. let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index e80d890cef..f2a0f1bbfe 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -265,6 +265,10 @@ type Slant = (Point, Point, (f64, f64), (f64, f64)); /// The line through two points. type Line = (Point, Point); +/// The x at which an edge crosses a height, and the line it was found along (`None` when it is +/// the edge's end). +type Crossing = (f64, Option); + /// The x of the line through `p` and `q` (not horizontal) at `y`. fn line_x(p: Point, q: Point, y: f64) -> f64 { p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x) @@ -426,20 +430,36 @@ fn band_intervals(polys: &[Vec], top: f64, bottom: f64) -> Vec<(f64, f64) let (edges, _, mut xs) = band_boundaries(polys, top, bottom); // The edges of contours that meet along an edge cross the middle of the band at the same x, // since each is crossed on the line through the same two points: the intervals on either side - // join there (an edge that bounds the shape there is still subtracted). Intervals with any gap + // join there (an edge that bounds the shape there is still subtracted). Crossings of other edges + // that round to the same x join only when both lie exactly at that x. Intervals with any gap // between them stay apart. - xs.sort_by(f64::total_cmp); - let mut mid: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect(); - mid.dedup_by(|next, prev| { - let seam = next.0 <= prev.1; - if seam { - prev.1 = prev.1.max(next.1); + let y = (top + bottom) * 0.5; + xs.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| line_order(a.1, b.1))); + let mut mid: Vec<(f64, f64)> = Vec::new(); + let mut last: Option = None; + for &[(s, ks), (t, kt)] in xs.as_chunks::<2>().0 { + match mid.last_mut() { + Some(prev) if s <= prev.1 && ((ks.is_some() && last == ks) || (exactly_at(last, s, y) && exactly_at(ks, s, y))) => prev.1 = prev.1.max(t), + _ => mid.push((s, t)), } - seam - }); + last = kt; + } subtract_intervals(&mid, &edges) } +/// An order of the lines crossings of the band's middle were found along, which keeps crossings +/// along one line next to each other; a crossing at an edge's end (`None`) comes first. +fn line_order(a: Option, b: Option) -> std::cmp::Ordering { + let key = |l: Option| l.map_or([f64::NEG_INFINITY; 4], |(p, q)| [p.x, p.y, q.x, q.y]); + key(a).iter().zip(key(b)).fold(std::cmp::Ordering::Equal, |o, (u, v)| o.then(u.total_cmp(&v))) +} + +/// Does a crossing of the height `y` at `x`, found along `line` (or at an edge's end when `None`), +/// lie exactly at `x`? +fn exactly_at(line: Option, x: f64, y: f64) -> bool { + line.is_none_or(|(p, q)| on_line_exactly(p, q, Point::new(x, y))) +} + /// The x at which `polys` (even-odd) is inside somewhere in the band `top..bottom`, sorted and /// disjoint: the intervals at the middle of the band and every x at which an edge lies within the /// band. A vertical line at any other x meets no edge inside the band, so it is inside somewhere in @@ -482,17 +502,19 @@ fn band_cover_closed(polys: &[Vec], top: f64, bottom: f64) -> Vec<(f64, f /// the band, stretches of edges that an even number of edges cover, and vertical edges. The flag is /// false when [`MAX_EDGE_PAIRS`] left edges unpaired, which can only add to the result. Last, the x /// at which the edges cross the middle of the band (even-odd, as [`poly_intervals`] finds them), -/// unsorted, each found along the line [`slant_boundaries`] gives its edge. -fn band_boundaries(polys: &[Vec], top: f64, bottom: f64) -> (Vec<(f64, f64)>, bool, Vec) { +/// unsorted, each found along the line [`slant_boundaries`] gives its edge, with that line (`None` +/// for a crossing at an edge's end). +fn band_boundaries(polys: &[Vec], top: f64, bottom: f64) -> (Vec<(f64, f64)>, bool, Vec) { let (mut flats, mut slants) = band_edges(polys, top, bottom); let (mut edges, paired, lines) = slant_boundaries(&mut slants); let y = (top + bottom) * 0.5; - let mut xs: Vec = slants.iter().zip(&lines).filter(|((p, q, ..), _)| p.y <= y && y < q.y).map(|(_, &(p, q))| line_x(p, q, y)).collect(); + let mut xs: Vec = + slants.iter().zip(&lines).filter(|((p, q, ..), _)| p.y <= y && y < q.y).map(|(_, &(p, q))| (line_x(p, q, y), Some((p, q)))).collect(); // When the middle is the band's bottom (a band of no height), the edges that start there cross // it but are not in the band: they cross it at their lower end. if y == bottom { let starts = polys.iter().flat_map(|poly| poly.iter().zip(poly.iter().cycle().skip(1))); - xs.extend(starts.filter(|(a, b)| a.y != b.y && a.y.min(b.y) == y).map(|(a, b)| if a.y < b.y { a.x } else { b.x })); + xs.extend(starts.filter(|(a, b)| a.y != b.y && a.y.min(b.y) == y).map(|(a, b)| (if a.y < b.y { a.x } else { b.x }, None))); } // The x covered by an odd number of the horizontal edges at one height: pairs of their // sorted ends. @@ -1364,6 +1386,29 @@ fn shared_slanted_edge_still_joins_near_canvas_limit() { assert_spans(&cells[0].spans(top, bottom), &[(-100.0 + 3.0 * bottom / 7.0, 100.0 + 3.0 * top / 7.0)]); } +/// A slanted edge and a vertical edge of two contours less than one unit in the last place apart +/// near the edge of the canvas, whose crossings of the band's middle round to the same x, are not +/// one edge: the line stops at the gap, drawn either way around. +#[cfg(test)] +#[test] +fn contours_whose_crossings_round_together_stay_apart() { + let x = 3_999_000.0; + let u = 2f64.powi(-31); + assert_eq!((x + u).to_bits(), x.to_bits() + 1); + let left: &[(f64, f64)] = &[(x - 100.0, 0.0), (x, 0.0), (x + u, 10.0), (x - 100.0, 10.0)]; + let right: &[(f64, f64)] = &[(x + u, 0.0), (x + 100.0, 0.0), (x + 100.0, 10.0), (x + u, 10.0)]; + let mirror = |c: &[(f64, f64)]| -> Vec<(f64, f64)> { c.iter().map(|&(px, py)| (px, 10.0 - py)).collect() }; + let (left_m, right_m) = (mirror(left), mirror(right)); + for frame in [closed(&[left, right]), closed(&[&right_m, &left_m])] { + let cells = Region::cells(&frame, &LayoutOptions::default(), &[]); + for (top, bottom) in [(8.0, 9.0), (1.0, 2.0)] { + let spans = cells[0].spans(top, bottom); + assert_eq!(spans.len(), 2, "{top}..{bottom}: {spans:?}"); + assert_spans(&spans, &[(x - 100.0, x + u), (x + u, x + 100.0)]); + } + } +} + /// Area type in two slanted contours four units in the last place apart near the edge of the canvas /// sets no line across the gap between them. #[cfg(test)] diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 4158d89330..0765b88aaf 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -192,6 +192,72 @@ fn nested_loans_of_one_name_use_each_documents_definition() { assert_eq!(db.generation(), plain); } +/// While one document's composite fonts are lent, a name only another document defines (the one +/// the database holds, or one an outer guard lends) is no composite font: type set in it lays out +/// as in a missing font, also after it was laid out in that definition with nothing lent. +#[test] +fn a_lent_document_does_not_use_another_documents_composite_font() { + let db = FontDb::with_font_dirs(vec![]); + let (a, b) = (roman_at("CF Only A", 50.0), roman_at("CF Only B", 50.0)); + // The session registered B's font, and A's (the same definition) as well. + db.set_composite_font(&a); + db.set_composite_font(&b); + let half = advance(&db, "JetBrains Mono", 10.0); + assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9); + let missing = advance(&db, "CF Nowhere", 20.0); + assert!((missing - half).abs() > 1e-6, "the missing font draws otherwise"); + // Lending looks the name up among the installed families, which catalogs them the first time. + assert!(!db.has_installed_family("CF Only A")); + let plain = db.generation(); + { + let _a = db.lend_composite_fonts(std::slice::from_ref(&a)); + assert!(db.composite_font("CF Only A").is_some()); + assert!(db.composite_font("cf only b").is_none()); + assert!(db.composite_fonts_for("CF Only B", "A").is_none()); + assert_ne!(db.generation(), plain, "type laid out with B's font is not reused"); + assert!((advance(&db, "CF Only B", 20.0) - missing).abs() < 1e-9); + assert!((advance(&db, "CF Only A", 20.0) - half).abs() < 1e-9); + // A document placed in A that defines B's font sees only its own. + let _b = db.lend_composite_fonts(std::slice::from_ref(&b)); + assert!(db.composite_font("CF Only A").is_none()); + assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9); + } + assert_eq!(db.generation(), plain); + assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9); +} + +/// A document that defines no composite font, drawn or lent as a document, uses none of the +/// database's. A temporary document that defines none, drawn inside another document's guard, +/// keeps that guard's fonts; with no guard at all, the database's stay in use. +#[test] +fn a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged() { + let db = FontDb::with_font_dirs(vec![]); + let (a, b) = (roman_at("CF Held A", 50.0), roman_at("CF Held B", 50.0)); + db.set_composite_font(&b); + let half = advance(&db, "JetBrains Mono", 10.0); + let missing = advance(&db, "CF Nowhere Else", 20.0); + let plain = db.generation(); + for lend in [FontDb::lend_composite_fonts, FontDb::lend_document_composite_fonts] { + let _none = lend(&db, &[]); + assert!(db.composite_font("CF Held B").is_none()); + assert_ne!(db.generation(), plain); + assert!((advance(&db, "CF Held B", 20.0) - missing).abs() < 1e-9); + } + { + let _a = db.lend_composite_fonts(std::slice::from_ref(&a)); + // A temporary document drawn inside it lends nothing and keeps A's fonts. + let _preview = db.lend_composite_fonts(&[]); + assert!(db.composite_font("CF Held A").is_some()); + assert!(db.composite_font("CF Held B").is_none()); + // A document placed in it that defines none uses none of A's. + let _placed = db.lend_document_composite_fonts(&[]); + assert!(db.composite_font("CF Held A").is_none()); + } + assert_eq!(db.generation(), plain); + assert!(db.composite_font("CF Held B").is_some()); + assert!((advance(&db, "CF Held B", 20.0) - half).abs() < 1e-9); +} + /// Two threads that lend different definitions of one name at once each lay out in their own. #[test] fn threads_lending_one_name_at_once_use_their_own_definitions() { diff --git a/crates/text/src/tests/justify.rs b/crates/text/src/tests/justify.rs index bc1e5b1b13..5c82700cde 100644 --- a/crates/text/src/tests/justify.rs +++ b/crates/text/src/tests/justify.rs @@ -668,6 +668,36 @@ fn a_right_to_left_line_with_a_tab_and_many_breaks_wraps_when_it_does_not_fit_as } } +/// A right-to-left line with a tab whose last character loses its Japanese–Latin space at the +/// line's end is measured with that space taken off where the character is drawn, before the tab: +/// right aligned, it ends on the frame's right edge, and it wraps when it does not fit as drawn. +#[test] +fn a_right_to_left_tabbed_line_is_measured_without_its_line_end_space() { + use vectorcraft_doc::Mojikumi; + // א (0), ב (2), tab (4), ג (5), 漢 (7), then a Hebrew word too long for the frame: the first + // line ends after 漢, which loses the quarter em it had before the Hebrew word. + let text = format!("אב\tג漢{}", "ד".repeat(40)); + let lay = |width: f64| { + let mut t = area(&text, width, Justify::Right, no_ranges()); + t.para.mojikumi = Mojikumi::GyomatsuYakumonoZenkaku; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + layout(db(), &t) + }; + let l = lay(300.0); + assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len()); + assert!(l.glyphs.iter().filter(|g| g.line == 0).all(|g| g.byte < 10), "the first line ends after 漢"); + let (_, right) = drawn_extent(&l, &text, 0); + assert!((right - 300.0).abs() < 1e-6, "ends on the right edge: {right}"); + assert!((l.lines[0].x1 - right).abs() < 1e-6, "the line's record ends where it is drawn: {}", l.lines[0].x1); + // Drawn, that line is as wide as the stop and the text after the tab (אב). In a frame a point + // narrower, less than the quarter em, it does not fit and the first line stays in the frame. + let after_tab: f64 = l.glyphs.iter().filter(|g| g.byte < 4).map(|g| g.advance).sum(); + let width = 150.0 + after_tab - 1.0; + let l = lay(width); + let (left, right) = drawn_extent(&l, &text, 0); + assert!(left >= -1e-6 && right <= width + 1e-6, "the first line stays in the frame: {left} {right} (frame {width})"); +} + /// Point type has no measure to fill: a justified line with a tab keeps its natural width. #[test] fn justified_point_type_with_a_tab_keeps_its_width() { diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 042a6f5174..269b7365d1 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -24,3 +24,5 @@ serde_json = { workspace = true } image = { workspace = true } # The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine). vectorcraft-text = { workspace = true, features = ["test-fonts", "bundled-fonts"] } +# How many times a path is flattened. +vectorcraft-doc = { workspace = true, features = ["spine-count"] } diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index 8f19259126..fe956691e1 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -42,13 +42,14 @@ //! Behavior: , //! . +use std::sync::Arc; + use serde_json::{Value, json}; -use vectorcraft_doc::blend::Spine; use vectorcraft_doc::objects_on_path::{ - CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops, + CENTER, Measured, Member, ObjectsOnPath, attachable, attachable_path, is_objects_on_path, layout, nth_length, }; use vectorcraft_doc::{Document, Node, NodeId, Selection}; -use vectorcraft_geom::{BezPath, Point, Vec2}; +use vectorcraft_geom::{BezPath, PathData, Point, Vec2}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -85,14 +86,20 @@ pub const MEMBER_RADIUS_PX: f32 = 4.5; /// How near (screen px) the pointer must be to hit a Select/Move widget. const MEMBER_HIT_PX: f64 = 6.0; -/// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with -/// its record, its children and its path: only when the selection is that one editable group, on -/// a path with length, and no anchors are Direct-Selected. -fn one_group<'a>( - doc: &'a Document, - selection: &Selection, - zoom: f64, -) -> Option<(NodeId, &'a ObjectsOnPath, &'a [std::sync::Arc], &'a vectorcraft_geom::PathData)> { +/// An Objects on Path group the widgets show for, with its record, its children, its path and the +/// path measured: one flattening serves every widget of the group. +struct Group<'a> { + id: NodeId, + rec: &'a ObjectsOnPath, + children: &'a [Arc], + path: &'a PathData, + measured: Arc, +} + +/// The Objects on Path group the widgets show for at `zoom` (screen px per document point): only +/// when the selection is that one editable group, on a path with length, and no anchors are +/// Direct-Selected. +fn one_group<'a>(doc: &'a Document, selection: &Selection, zoom: f64) -> Option> { if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 { return None; } @@ -103,8 +110,39 @@ fn one_group<'a>( let n = doc.node(*group)?; let rec = n.extras().objects_on_path.as_deref()?; let children = n.children()?; - let path = attachable_path(children.iter().find(|c| c.id == rec.path)?)?; - Some((*group, rec, children.as_slice(), path)) + let (path, measured) = attachable(children.iter().find(|c| c.id == rec.path)?)?; + Some(Group { id: *group, rec, children: children.as_slice(), path, measured: Arc::new(measured) }) +} + +/// The path of an Objects on Path group as measured when a widget drag began, kept for the drag: +/// the previews change the group's record and its objects, not its path. +#[derive(Clone, Debug)] +struct Kept { + group: NodeId, + path: PathData, + measured: Arc, +} + +impl Kept { + fn of(g: &Group) -> Self { + Self { group: g.id, path: g.path.clone(), measured: Arc::clone(&g.measured) } + } + + /// The group's path in `doc` measured: the kept measurement while the path is as it was, else + /// the path measured again and kept. `None` when the group no longer has a path with length. + fn now(&mut self, doc: &Document) -> Option> { + let n = doc.node(self.group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let node = n.children()?.iter().find(|c| c.id == rec.path)?; + if let vectorcraft_doc::NodeKind::Path { path, guide: false, .. } = &node.kind + && *path == self.path + { + return Some(Arc::clone(&self.measured)); + } + let (path, measured) = attachable(node)?; + *self = Self { group: self.group, path: path.clone(), measured: Arc::new(measured) }; + Some(Arc::clone(&self.measured)) + } } /// The direction the top of the object `m` faces: the -y direction of the frame it is drawn in @@ -148,22 +186,27 @@ impl RotateAllWidget { /// selection is one editable Objects on Path group with at least one object on a path with /// length, and no anchors are Direct-Selected. pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option { - let (group, rec, children, path) = one_group(doc, selection, zoom)?; - let first = rec.members.first()?; - let (pivot, _) = stop(path, rec.members.len(), rec.spacing, rec.start, 0)?; - let at = beside(doc, children, first, pivot, top(first), ROTATE_ALL_OFFSET_PX / zoom)?; - Some(Self { group, pivot, at, rotate: rec.rotate }) + Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom) } - /// The widget of `cx` when `p` is within reach of it. - fn at_point(cx: &ToolContext, p: Point) -> Option { - Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(ROTATE_ALL_HIT_PX)) + /// The widget of the group `g`. + fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option { + let rec = g.rec; + let first = rec.members.first()?; + let (pivot, _) = g.measured.stop(rec.members.len(), rec.spacing, rec.start, 0)?; + let at = beside(doc, g.children, first, pivot, top(first), ROTATE_ALL_OFFSET_PX / zoom)?; + Some(Self { group: g.id, pivot, at, rotate: rec.rotate }) + } + + /// The widget of the group `g` of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option { + Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(ROTATE_ALL_HIT_PX)) } } /// Is `p` over the Rotate All widget? pub fn over_rotate_all(cx: &ToolContext, p: Point) -> bool { - RotateAllWidget::at_point(cx, p).is_some() + one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| RotateAllWidget::at_point(cx, &g, p)).is_some() } /// Dragging the Rotate All widget: the group's Rotate follows the angle the pointer turns about @@ -191,7 +234,12 @@ impl RotateAllDrag { /// Start a drag when the press `ev` is on the Rotate All widget. pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { - let widget = RotateAllWidget::at_point(cx, ev.pos)?; + Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev) + } + + /// [`RotateAllDrag::hit`] on the group `g` of `cx`. + fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option { + let widget = RotateAllWidget::at_point(cx, g, ev.pos)?; let d = ev.pos - widget.pivot; let last = d.y.atan2(d.x); let last = if last.is_finite() { last } else { 0.0 }; @@ -272,17 +320,22 @@ impl SpaceWidget { /// length, and no anchors are Direct-Selected. It sits beyond the last object's bounds, ahead /// along the path's direction there. pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option { - let (group, rec, children, path) = one_group(doc, selection, zoom)?; + Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom) + } + + /// The widget of the group `g`. + fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option { + let (group, rec) = (g.id, g.rec); let count = rec.members.len(); if count < 2 { return None; } - let (len, closed) = path_length(path)?; + let (len, closed) = (g.measured.length(), g.measured.closed()); let (start, gap) = layout(len, closed, count, rec.spacing, rec.start); - let (last, angle) = stop(path, count, rec.spacing, rec.start, count - 1)?; + let (last, angle) = g.measured.stop(count, rec.spacing, rec.start, count - 1)?; let dir = Vec2::new(angle.cos(), angle.sin()); let m = rec.members.last()?; - let b = children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?; + let b = g.children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?; let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; let reach = corners.iter().map(|c| (*c - last).dot(dir)).fold(0.0, f64::max); let at = last + dir * (reach + SPACE_OFFSET_PX / zoom); @@ -291,29 +344,26 @@ impl SpaceWidget { finite.then_some(Self { group, last, last_length, at, dir, len, closed, count, start, gap, saved: (rec.spacing, rec.start) }) } - /// The widget of `cx` when `p` is within reach of it. - fn at_point(cx: &ToolContext, p: Point) -> Option { - Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(SPACE_HIT_PX)) + /// The widget of the group `g` of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option { + Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(SPACE_HIT_PX)) } } /// Is `p` over the Space widget? pub fn over_space(cx: &ToolContext, p: Point) -> bool { - SpaceWidget::at_point(cx, p).is_some() + one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| SpaceWidget::at_point(cx, &g, p)).is_some() } -/// The distance along the path of the Objects on Path group of `w` in `doc` to the point nearest -/// `p` ([`Spine::nearest`]: past the ends of an open path, along the lines of its end segments), -/// staying near `from` when the pointer is following the path from there. The line from the last -/// object's attachment point through the widget counts as the path too, as far as the pointer is -/// ahead of that object along it, so a widget beyond a corner measures from where it is. The flag -/// says whether the point is on that line, where the distance runs on past the end of a closed path -/// instead of round it. -fn length_near(doc: &Document, w: &SpaceWidget, p: Point, from: Option) -> Option<(f64, bool)> { - let n = doc.node(w.group)?; - let rec = n.extras().objects_on_path.as_deref()?; - let path = attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?; - let spine = Spine::new(path)?; +/// The distance along the path of the Objects on Path group of `w`, `measured`, to the point nearest +/// `p` ([`vectorcraft_doc::blend::Spine::nearest`]: past the ends of an open path, along the lines +/// of its end segments), staying near `from` when the pointer is following the path from there. The +/// line from the last object's attachment point through the widget counts as the path too, as far +/// as the pointer is ahead of that object along it, so a widget beyond a corner measures from where +/// it is. The flag says whether the point is on that line, where the distance runs on past the end +/// of a closed path instead of round it. +fn length_near(measured: &Measured, w: &SpaceWidget, p: Point, from: Option) -> Option<(f64, bool)> { + let spine = measured.spine(); let bias = |s: f64| from.filter(|f| f.is_finite()).map_or(0.0, |f| spine.apart(s, f) / 2.0); let ahead = (p - w.last).dot(w.dir); let line = (ahead >= 0.0).then(|| { @@ -334,6 +384,8 @@ fn length_near(doc: &Document, w: &SpaceWidget, p: Point, from: Option) -> #[derive(Clone, Debug)] pub struct SpaceDrag { widget: SpaceWidget, + /// The group's path, measured. + path: Kept, start: Point, began: bool, /// The distance along the path to the point nearest the pointer at the last drag (points). @@ -353,17 +405,23 @@ impl SpaceDrag { /// Start a drag when the press `ev` is on the Space widget. pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { - let widget = SpaceWidget::at_point(cx, ev.pos)?; - let (last, _) = length_near(cx.doc, &widget, ev.pos, None)?; - Some(Self { widget, start: ev.pos, began: false, last, along: 0.0, gap: widget.gap, at: ev.pos }) + Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev) + } + + /// [`SpaceDrag::hit`] on the group `g` of `cx`. + fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option { + let widget = SpaceWidget::at_point(cx, g, ev.pos)?; + let (last, _) = length_near(&g.measured, &widget, ev.pos, None)?; + Some(Self { widget, path: Kept::of(g), start: ev.pos, began: false, last, along: 0.0, gap: widget.gap, at: ev.pos }) } /// The pointer moved to `p` from `self.at`: add the distance it moved along the path. On the /// line through the widget, from a point ahead of the last object, that is how far it moved /// along the line; otherwise it takes the shorter way round a closed path. fn follow(&mut self, doc: &Document, p: Point) { + let Some(measured) = self.path.now(doc) else { return }; let w = &self.widget; - let Some((t, on_line)) = length_near(doc, w, p, Some(self.last)) else { return }; + let Some((t, on_line)) = length_near(&measured, w, p, Some(self.last)) else { return }; let before = (self.at - w.last).dot(w.dir); let step = if on_line && before >= 0.0 { (p - w.last).dot(w.dir) - before @@ -469,10 +527,15 @@ impl MoveAllWidget { /// the distance from the first object to the last is shorter than the path. It sits beyond the /// first object's bounds, on the side its bottom faces. pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option { - let (group, rec, children, path) = one_group(doc, selection, zoom)?; + Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom) + } + + /// The widget of the group `g`. + fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option { + let (group, rec, children) = (g.id, g.rec, g.children); let count = rec.members.len(); let first = rec.members.first()?; - let (len, closed) = path_length(path)?; + let (len, closed) = (g.measured.length(), g.measured.closed()); let (start, gap) = layout(len, closed, count, rec.spacing, rec.start); let most = if closed { len } else { len - gap * count.saturating_sub(1) as f64 }; if !closed && (!most.is_finite() || most <= 1e-9) { @@ -481,21 +544,21 @@ impl MoveAllWidget { // On an open path, Auto and a Space value the path is too short for draw the objects apart // by what is left of the path after the start, so another start would change the distance. let keeps = closed || count < 2 || rec.spacing.is_some_and(|g| (g - gap).abs() < 1e-9); - let (at_first, _) = stop(path, count, rec.spacing, rec.start, 0)?; + let (at_first, _) = g.measured.stop(count, rec.spacing, rec.start, 0)?; let at = beside(doc, children, first, at_first, -top(first), MOVE_ALL_OFFSET_PX / zoom)?; let finite = [len, start, most, rec.start].iter().all(|v| v.is_finite()); finite.then_some(Self { group, first: at_first, at, len, closed, start, most, gap: (!keeps).then_some(gap), saved: rec.start }) } - /// The widget of `cx` when `p` is within reach of it. - fn at_point(cx: &ToolContext, p: Point) -> Option { - Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(MOVE_ALL_HIT_PX)) + /// The widget of the group `g` of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option { + Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(MOVE_ALL_HIT_PX)) } } /// Is `p` over the Move All widget? pub fn over_move_all(cx: &ToolContext, p: Point) -> bool { - MoveAllWidget::at_point(cx, p).is_some() + one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| MoveAllWidget::at_point(cx, &g, p)).is_some() } /// Dragging the Move All widget: the distance the pointer moves along the path moves every object @@ -504,6 +567,8 @@ pub fn over_move_all(cx: &ToolContext, p: Point) -> bool { #[derive(Clone, Debug)] pub struct MoveAllDrag { widget: MoveAllWidget, + /// The group's path, measured. + path: Kept, start: Point, began: bool, /// The distance along the path to the point nearest the pointer at the last drag (points). @@ -521,9 +586,14 @@ impl MoveAllDrag { /// Start a drag when the press `ev` is on the Move All widget. pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { - let widget = MoveAllWidget::at_point(cx, ev.pos)?; - let (_, last) = group_spine(cx.doc, widget.group)?.nearest(ev.pos, Some(widget.start))?; - last.is_finite().then_some(Self { widget, start: ev.pos, began: false, last, to: widget.start, at: ev.pos }) + Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev) + } + + /// [`MoveAllDrag::hit`] on the group `g` of `cx`. + fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option { + let widget = MoveAllWidget::at_point(cx, g, ev.pos)?; + let (_, last) = g.measured.spine().nearest(ev.pos, Some(widget.start))?; + last.is_finite().then_some(Self { widget, path: Kept::of(g), start: ev.pos, began: false, last, to: widget.start, at: ev.pos }) } /// The pointer moved to `p`: move the first object as far along the path as the point nearest @@ -531,8 +601,8 @@ impl MoveAllDrag { /// the first anchor and [`MoveAllWidget::most`], so moving back from beyond an end moves the /// objects back at once. fn follow(&mut self, doc: &Document, p: Point) { + let Some((_, t)) = self.path.now(doc).and_then(|m| m.spine().nearest(p, Some(self.last))) else { return }; let w = &self.widget; - let Some((_, t)) = group_spine(doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return }; let step = if w.closed { (t - self.last + w.len / 2.0).rem_euclid(w.len) - w.len / 2.0 } else { t - self.last }; if !t.is_finite() || !step.is_finite() { return; @@ -604,8 +674,13 @@ impl MemberWidget { /// its order along the path that is neither hidden nor locked: only when the selection is one /// editable Objects on Path group on a path with length, and no anchors are Direct-Selected. pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec { - let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] }; - let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] }; + one_group(doc, selection, zoom).map(|g| Self::in_group(doc, selection, &g)).unwrap_or_default() + } + + /// The widgets of the group `g`, the selection. + fn in_group(doc: &Document, selection: &Selection, g: &Group) -> Vec { + let (group, rec) = (g.id, g.rec); + let at = g.measured.stops(rec.members.len(), rec.spacing, rec.start); let editable = doc.editable(); rec.members .iter() @@ -616,17 +691,16 @@ impl MemberWidget { .collect() } - /// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the - /// chosen one, else the first along the path. - fn at_point(cx: &ToolContext, p: Point) -> Option { + /// The widget of the group `g` of `cx` nearest `p`, when `p` is within reach of it; of widgets + /// as near, the chosen one, else the first along the path. + fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option { let reach = cx.tol(MEMBER_HIT_PX); // Every attachment point lies on the path: a pointer beyond reach of its bounds is over none. - let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?; - if !path.control_bounds()?.inflate(reach, reach).contains(p) { + if !g.path.control_bounds()?.inflate(reach, reach).contains(p) { return None; } let mut best: Option<(f64, Self)> = None; - for w in Self::all(cx.doc, cx.selection, cx.zoom) { + for w in Self::in_group(cx.doc, cx.selection, g) { let d = w.at.distance(p); let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen)); if d <= reach && nearer { @@ -639,17 +713,87 @@ impl MemberWidget { /// Is `p` over a Select/Move widget? pub fn over_member(cx: &ToolContext, p: Point) -> bool { - MemberWidget::at_point(cx, p).is_some() + one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| MemberWidget::at_point(cx, &g, p)).is_some() } /// The highlight of the Select/Move widget under `p`, if any: a ring around it. pub fn member_hover(cx: &ToolContext, p: Point) -> Option { - let w = MemberWidget::at_point(cx, p)?; + let w = MemberWidget::at_point(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, p)?; let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0); let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r)); Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false }) } +/// The widgets of the selection at `zoom` (screen px per document point), each as its own `of` or +/// `all` gives it, from one measurement of the group's path: for drawing them together. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Widgets { + pub members: Vec, + pub rotate_all: Option, + pub space: Option, + pub move_all: Option, +} + +impl Widgets { + pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Self { + let Some(g) = one_group(doc, selection, zoom) else { return Self::default() }; + Self { + members: MemberWidget::in_group(doc, selection, &g), + rotate_all: RotateAllWidget::in_group(doc, &g, zoom), + space: SpaceWidget::in_group(doc, &g, zoom), + move_all: MoveAllWidget::in_group(doc, &g, zoom), + } + } +} + +/// A widget of a selected Objects on Path group. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WidgetKind { + RotateAll, + Space, + MoveAll, + Member, +} + +/// The widget `p` is over, of the Rotate All, Space, Move All and Select/Move widgets in that +/// order, from one measurement of the group's path: as [`over_rotate_all`], [`over_space`], +/// [`over_move_all`] and [`over_member`] answer in turn. +pub fn widget_at(cx: &ToolContext, p: Point) -> Option { + let g = one_group(cx.doc, cx.selection, cx.zoom)?; + if RotateAllWidget::at_point(cx, &g, p).is_some() { + Some(WidgetKind::RotateAll) + } else if SpaceWidget::at_point(cx, &g, p).is_some() { + Some(WidgetKind::Space) + } else if MoveAllWidget::at_point(cx, &g, p).is_some() { + Some(WidgetKind::MoveAll) + } else { + MemberWidget::at_point(cx, &g, p).map(|_| WidgetKind::Member) + } +} + +/// A drag of a widget of a selected Objects on Path group. +#[derive(Clone, Debug)] +pub enum WidgetDrag { + RotateAll(RotateAllDrag), + Space(SpaceDrag), + MoveAll(MoveAllDrag), + Member(MemberDrag), +} + +impl WidgetDrag { + /// The drag the press `ev` starts, of the Rotate All, Space, Move All and Select/Move widgets + /// in that order, from one measurement of the group's path: as [`RotateAllDrag::hit`], + /// [`SpaceDrag::hit`], [`MoveAllDrag::hit`] and [`MemberDrag::hit`] answer in turn. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let g = one_group(cx.doc, cx.selection, cx.zoom)?; + RotateAllDrag::hit_in(cx, &g, ev) + .map(Self::RotateAll) + .or_else(|| SpaceDrag::hit_in(cx, &g, ev).map(Self::Space)) + .or_else(|| MoveAllDrag::hit_in(cx, &g, ev).map(Self::MoveAll)) + .or_else(|| MemberDrag::hit_in(cx, &g, ev).map(Self::Member)) + } +} + /// Dragging a Select/Move widget: the object goes to the place along the path nearest the /// pointer, and the objects between it and its place move one place toward where it was, except /// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond @@ -661,6 +805,8 @@ pub fn member_hover(cx: &ToolContext, p: Point) -> Option { #[derive(Clone, Debug)] pub struct MemberDrag { widget: MemberWidget, + /// The group's path, measured. + path: Kept, /// The objects in their order along the path when the press began. order: Vec, /// The distance along the path to each place (points), in that order. @@ -687,10 +833,14 @@ impl MemberDrag { /// Start a drag when the press `ev` is on a Select/Move widget. pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { - let widget = MemberWidget::at_point(cx, ev.pos)?; - let n = cx.doc.node(widget.group)?; - let rec = n.extras().objects_on_path.as_deref()?; - let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?; + Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev) + } + + /// [`MemberDrag::hit`] on the group `g` of `cx`. + fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option { + let widget = MemberWidget::at_point(cx, g, ev.pos)?; + let rec = g.rec; + let (len, closed) = (g.measured.length(), g.measured.closed()); let count = rec.members.len(); let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start); let places: Vec = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect(); @@ -698,7 +848,7 @@ impl MemberDrag { let editable = cx.doc.editable(); let open = rec.members.iter().map(|m| editable(m.id)).collect(); let order = rec.members.iter().map(|m| m.id).collect(); - Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) + Some(Self { widget, path: Kept::of(g), order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) } /// How far apart along the path the distances `a` and `b` are: the shorter way round a closed @@ -733,8 +883,8 @@ impl MemberDrag { self.began = true; out.push(Action::Begin("Move Object on Path".into())); } - let w = &self.widget; - let Some(spine) = group_spine(cx.doc, w.group) else { return out }; + let Some(measured) = self.path.now(cx.doc) else { return out }; + let (w, spine) = (&self.widget, measured.spine()); let Some((near, t)) = spine.nearest(p, None) else { return out }; // Where the path passes as near the pointer more than once, the point nearest along the path // to where the drag was counts. @@ -767,13 +917,6 @@ impl MemberDrag { } } -/// The path of the Objects on Path group `g` of `doc`, to measure along. -fn group_spine(doc: &Document, g: NodeId) -> Option { - let n = doc.node(g)?; - let rec = n.extras().objects_on_path.as_deref()?; - Spine::new(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?) -} - /// The path a click at `p` attaches the selected objects to: the path object hit there, when it can /// take objects, is in no Objects on Path group, is not a clipping path or part of a compound path, /// blend, envelope, repeat or Intertwine group ([`vectorcraft_doc::objects_on_path::held`]), and @@ -781,14 +924,13 @@ fn group_spine(doc: &Document, g: NodeId) -> Option { pub fn target(cx: &ToolContext, p: Point) -> Option<(NodeId, BezPath)> { let h = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?; let n = cx.doc.node(h.leaf)?; - let path = attachable_path(n)?; if h.ancestry.iter().filter(|a| **a != h.leaf).any(|a| cx.doc.node(*a).is_some_and(is_objects_on_path)) { return None; } - if vectorcraft_doc::objects_on_path::held(cx.doc, h.leaf).is_some() { + if vectorcraft_doc::objects_on_path::held(cx.doc, h.leaf).is_some() || !cx.selection.objects.iter().any(|id| *id != h.leaf) { return None; } - cx.selection.objects.iter().any(|id| *id != h.leaf).then(|| (h.leaf, path.to_bezpath())) + attachable_path(n).map(|path| (h.leaf, path.to_bezpath())) } pub struct ObjectsOnPathTool { @@ -856,28 +998,17 @@ impl Tool for ObjectsOnPathTool { self.pointer = (ev.kind == PointerKind::Move).then_some(p); match ev.kind { PointerKind::Move => { - let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p); - self.hover = if widget { None } else { target(cx, p) }; + self.hover = if widget_at(cx, p).is_some() { None } else { target(cx, p) }; vec![] } PointerKind::Down => { - if let Some(d) = RotateAllDrag::hit(cx, ev) { - self.rotating = Some(d); - self.hover = None; - return vec![]; - } - if let Some(d) = SpaceDrag::hit(cx, ev) { - self.spacing = Some(d); - self.hover = None; - return vec![]; - } - if let Some(d) = MoveAllDrag::hit(cx, ev) { - self.moving = Some(d); - self.hover = None; - return vec![]; - } - if let Some(d) = MemberDrag::hit(cx, ev) { - self.member = Some(d); + if let Some(d) = WidgetDrag::hit(cx, ev) { + match d { + WidgetDrag::RotateAll(d) => self.rotating = Some(d), + WidgetDrag::Space(d) => self.spacing = Some(d), + WidgetDrag::MoveAll(d) => self.moving = Some(d), + WidgetDrag::Member(d) => self.member = Some(d), + } self.hover = None; return vec![]; } @@ -917,17 +1048,21 @@ impl Tool for ObjectsOnPathTool { if self.member.is_some() { return Cursor::Move; } - let idle = self.spacing.is_none() && self.moving.is_none(); - if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) { - Cursor::Rotate - } else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) { - Cursor::ArrowHollow - } else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) { - Cursor::Move - } else if target(cx, p).is_some() { - Cursor::ObjectsOnPath - } else { - Cursor::Arrow + if self.rotating.is_some() { + return Cursor::Rotate; + } + if self.spacing.is_some() { + return Cursor::ArrowHollow; + } + if self.moving.is_some() { + return Cursor::Move; + } + match widget_at(cx, p) { + Some(WidgetKind::RotateAll) => Cursor::Rotate, + Some(WidgetKind::Space) => Cursor::ArrowHollow, + Some(WidgetKind::MoveAll | WidgetKind::Member) => Cursor::Move, + None if target(cx, p).is_some() => Cursor::ObjectsOnPath, + None => Cursor::Arrow, } } @@ -1405,7 +1540,10 @@ mod tests { gap: 100.0, saved: (None, 0.0), }; - let moved = |along| SpaceDrag { widget: w, start: Point::ZERO, began: true, last: 0.0, along, gap: w.gap, at: Point::ZERO }; + let line = shapes::line(Point::ZERO, Point::new(400.0, 0.0)); + let path = Kept { group: NodeId(1), measured: Arc::new(Measured::new(&line).unwrap()), path: line }; + let moved = + |along| SpaceDrag { widget: w, path: path.clone(), start: Point::ZERO, began: true, last: 0.0, along, gap: w.gap, at: Point::ZERO }; assert_eq!(moved(30.0).spacing_at(false), (100.0, 0.0), "a quarter of the way round at most"); assert_eq!(moved(-60.0).spacing_at(false), (80.0, 0.0)); assert_eq!(moved(-60.0).spacing_at(true), (60.0, 310.0), "the span of 180 centered on the first anchor"); @@ -1958,4 +2096,168 @@ mod tests { t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one"); } + + /// What `f` returns, and how many times it flattened a path. + fn flattenings(f: impl FnOnce() -> T) -> (T, usize) { + let before = vectorcraft_doc::blend::spines_built(); + let out = f(); + (out, vectorcraft_doc::blend::spines_built() - before) + } + + /// Space 100 from 50 on the line from (0, 300) to (400, 300): the objects attach at (50, 300) + /// and (150, 300). The Rotate All widget is at (50, 274), the Move All widget at (50, 326) and + /// the Space widget at (176, 300). + #[test] + fn each_widget_lookup_flattens_the_path_once() { + let (d, g, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + // Points to 1/1,000 pt, as the path is measured along its flattening. + let close = |a: Point, b: Point| a.distance(b) < 1e-3; + let (w, n) = flattenings(|| RotateAllWidget::of(&d, &sel, 1.0)); + assert_eq!(n, 1, "Rotate All"); + let w = w.unwrap(); + assert!(w.group == g && w.rotate == 0.0, "{w:?}"); + assert!(close(w.pivot, Point::new(50.0, 300.0)) && close(w.at, Point::new(50.0, 274.0)), "{w:?}"); + let (w, n) = flattenings(|| SpaceWidget::of(&d, &sel, 1.0)); + assert_eq!(n, 1, "Space"); + let w = w.unwrap(); + assert!(close(w.last, Point::new(150.0, 300.0)) && close(w.at, Point::new(176.0, 300.0)), "{w:?}"); + assert!((w.last_length - 150.0).abs() < 1e-3 && (w.len - 400.0).abs() < 1e-3 && !w.closed && w.count == 2, "{w:?}"); + assert!((w.start - 50.0).abs() < 1e-3 && (w.gap - 100.0).abs() < 1e-3 && w.saved == (Some(100.0), 50.0), "{w:?}"); + let (w, n) = flattenings(|| MoveAllWidget::of(&d, &sel, 1.0)); + assert_eq!(n, 1, "Move All"); + let w = w.unwrap(); + assert!(close(w.first, Point::new(50.0, 300.0)) && close(w.at, Point::new(50.0, 326.0)), "{w:?}"); + assert!((w.len - 400.0).abs() < 1e-3 && (w.start - 50.0).abs() < 1e-3 && (w.most - 300.0).abs() < 1e-3, "{w:?}"); + assert!(w.gap.is_none() && w.saved == 50.0 && !w.closed, "{w:?}"); + let (ws, n) = flattenings(|| MemberWidget::all(&d, &sel, 1.0)); + assert_eq!(n, 1, "Select/Move"); + assert_eq!(ws.iter().map(|w| w.index).collect::>(), [0, 1]); + assert!(close(ws[0].at, Point::new(50.0, 300.0)) && close(ws[1].at, Point::new(150.0, 300.0)), "{ws:?}"); + // Each hit test, over its widget. + type Over = fn(&ToolContext, Point) -> bool; + let hits: [(&str, Over, Point); 4] = [ + ("Rotate All", over_rotate_all, Point::new(50.0, 274.0)), + ("Space", over_space, Point::new(176.0, 300.0)), + ("Move All", over_move_all, Point::new(50.0, 326.0)), + ("Select/Move", over_member, Point::new(150.0, 300.0)), + ]; + for (what, over, p) in hits { + assert_eq!(flattenings(|| over(&c, p)), (true, 1), "{what}"); + } + assert_eq!(flattenings(|| member_hover(&c, Point::new(150.0, 300.0)).is_some()), (true, 1)); + // A group of one object has no Space widget, and its objects no room to move on an open + // path: one flattening finds that. + let (d1, sel1) = single_doc(); + assert_eq!(flattenings(|| SpaceWidget::of(&d1, &sel1, 1.0)), (None, 1)); + let (d2, _, sel2) = group_doc(0.0); + assert_eq!(flattenings(|| MoveAllWidget::of(&d2, &sel2, 1.0)), (None, 1)); + // Nothing flattens for a selection the widgets do not show for. + assert_eq!(flattenings(|| Widgets::of(&d, &Selection::default(), 1.0)), (Widgets::default(), 0)); + } + + /// All four kinds of widget come from one flattening, as each one's own lookup gives it; and the + /// widget under the pointer, in the order a press takes them, from one flattening too. + #[test] + fn the_widgets_of_a_group_share_one_flattening() { + let (d, _, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let (all, n) = flattenings(|| Widgets::of(&d, &sel, 1.0)); + assert_eq!(n, 1); + assert_eq!(all.members, MemberWidget::all(&d, &sel, 1.0)); + assert_eq!(all.rotate_all, RotateAllWidget::of(&d, &sel, 1.0)); + assert_eq!(all.space, SpaceWidget::of(&d, &sel, 1.0)); + assert_eq!(all.move_all, MoveAllWidget::of(&d, &sel, 1.0)); + assert!(all.rotate_all.is_some() && all.space.is_some() && all.move_all.is_some() && all.members.len() == 2); + let cases = [ + (Point::new(50.0, 274.0), Some(WidgetKind::RotateAll)), + (Point::new(176.0, 300.0), Some(WidgetKind::Space)), + (Point::new(50.0, 326.0), Some(WidgetKind::MoveAll)), + (Point::new(150.0, 300.0), Some(WidgetKind::Member)), + // On the path between the objects: over none of them, after every one is tested. + (Point::new(250.0, 300.0), None), + ]; + for (p, kind) in cases { + assert_eq!(flattenings(|| widget_at(&c, p)), (kind, 1), "{p:?}"); + } + // A pointer move and the cursor of each tool look once. + for t in [Box::new(crate::select::SelectionTool::default()) as Box, Box::new(ObjectsOnPathTool::default())] { + for (p, cursor) in [(Point::new(50.0, 274.0), Cursor::Rotate), (Point::new(176.0, 300.0), Cursor::ArrowHollow)] { + assert_eq!(flattenings(|| t.cursor(&c, p, Mods::default())), (cursor, 1), "{}", t.id()); + } + } + let mut t = ObjectsOnPathTool::default(); + assert_eq!(flattenings(|| t.pointer(&c, &ev(PointerKind::Move, 250.0, 300.0))), (vec![], 1)); + } + + /// A press on each widget measures the path once, and the drag that follows measures it no more + /// while the path stays as it was. + #[test] + fn a_widget_drag_flattens_the_path_once() { + let (d, g, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let presses = [ + ("Rotate All", Point::new(50.0, 274.0), Point::new(24.0, 300.0)), + ("Space", Point::new(176.0, 300.0), Point::new(226.0, 300.0)), + ("Move All", Point::new(50.0, 326.0), Point::new(100.0, 326.0)), + ("Select/Move", Point::new(150.0, 300.0), Point::new(40.0, 300.0)), + ]; + let tools: [Box Box>; 2] = + [Box::new(|| Box::new(crate::select::SelectionTool::default())), Box::new(|| Box::new(ObjectsOnPathTool::default()))]; + for new in &tools { + for (what, at, to) in presses { + let mut t = new(); + assert_eq!(flattenings(|| t.pointer(&c, &ev(PointerKind::Down, at.x, at.y))), (vec![], 1), "{what} {}", t.id()); + assert!(t.busy(), "{what} {}", t.id()); + let (acts, n) = flattenings(|| t.pointer(&c, &ev(PointerKind::Drag, to.x, to.y))); + assert_eq!(n, 0, "{what} {}", t.id()); + assert!(matches!(acts.last(), Some(Action::Preview(..))), "{what} {}: {acts:?}", t.id()); + let (acts, n) = flattenings(|| t.pointer(&c, &ev(PointerKind::Drag, to.x + 1.0, to.y))); + assert_eq!(n, 0, "{what} {}: {acts:?}", t.id()); + } + } + // The drags as each one's own press starts it. + type Hit = fn(&ToolContext, &PointerEvent) -> bool; + let checks: [(&str, Hit, Point); 4] = [ + ("Rotate All", |c, e| RotateAllDrag::hit(c, e).is_some(), Point::new(50.0, 274.0)), + ("Space", |c, e| SpaceDrag::hit(c, e).is_some(), Point::new(176.0, 300.0)), + ("Move All", |c, e| MoveAllDrag::hit(c, e).is_some(), Point::new(50.0, 326.0)), + ("Select/Move", |c, e| MemberDrag::hit(c, e).is_some(), Point::new(150.0, 300.0)), + ]; + for (what, hit, p) in checks { + assert_eq!(flattenings(|| hit(&c, &ev(PointerKind::Down, p.x, p.y))), (true, 1), "{what}"); + } + // The Move All drag moves the objects by what the pointer moves along the path: 50 pt. + let mut d = MoveAllDrag::hit(&c, &ev(PointerKind::Down, 50.0, 326.0)).unwrap(); + let acts = d.drag(&c, Point::new(100.0, 326.0)); + assert!(matches!(acts.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{acts:?}"); + assert_eq!(moved(&acts), Some((100.0, None)), "{acts:?}"); + } + + /// A drag whose group's path changes while it goes on measures the new path, once. + #[test] + fn a_widget_drag_follows_a_path_that_changed() { + let (d, g, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut drag = MoveAllDrag::hit(&c, &ev(PointerKind::Down, 50.0, 326.0)).unwrap(); + // The path now runs from (100, 300) to (500, 300): the pointer at (200, 326) is 100 pt along + // it, 50 pt on from where the press was. + let mut longer = d.clone(); + let line = longer.node(g).and_then(|n| n.children()).unwrap()[0].id; + if let Some(NodeKind::Path { path, .. }) = longer.node_mut(line).map(|n| &mut n.kind) { + *path = shapes::line(Point::new(100.0, 300.0), Point::new(500.0, 300.0)); + } + let c = cx(&longer, &sel, &paint); + let (acts, n) = flattenings(|| drag.drag(&c, Point::new(200.0, 326.0))); + assert_eq!(n, 1); + assert!(matches!(acts.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{acts:?}"); + assert_eq!(moved(&acts), Some((100.0, None)), "{acts:?}"); + let (acts, n) = flattenings(|| drag.drag(&c, Point::new(300.0, 326.0))); + assert_eq!(n, 0); + assert_eq!(moved(&acts), Some((200.0, None)), "{acts:?}"); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 76e4a5c977..ef8e3d4817 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -48,9 +48,7 @@ use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_dra use crate::blendwidget::{self, BlendWidgetDrag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{ - MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space, -}; +use crate::objects_on_path::{MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, WidgetDrag, WidgetKind, member_hover, widget_at}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -1269,14 +1267,11 @@ impl Tool for SelectionTool { if blendwidget::over_widget(cx, p) { return Cursor::ArrowHollow; } - if over_rotate_all(cx, p) { - return Cursor::Rotate; - } - if over_space(cx, p) { - return Cursor::ArrowHollow; - } - if over_move_all(cx, p) || over_member(cx, p) { - return Cursor::Move; + match widget_at(cx, p) { + Some(WidgetKind::RotateAll) => return Cursor::Rotate, + Some(WidgetKind::Space) => return Cursor::ArrowHollow, + Some(WidgetKind::MoveAll | WidgetKind::Member) => return Cursor::Move, + None => {} } if over_bracket(cx, p) { return Cursor::PathBracket; @@ -1651,20 +1646,13 @@ impl SelectionTool { self.state = State::BlendWidget(d); return Some(vec![]); } - if let Some(d) = RotateAllDrag::hit(cx, ev) { - self.state = State::RotateAll(d); - return Some(vec![]); - } - if let Some(d) = SpaceDrag::hit(cx, ev) { - self.state = State::Space(d); - return Some(vec![]); - } - if let Some(d) = MoveAllDrag::hit(cx, ev) { - self.state = State::MoveAll(d); - return Some(vec![]); - } - if let Some(d) = MemberDrag::hit(cx, ev) { - self.state = State::Member(d); + if let Some(d) = WidgetDrag::hit(cx, ev) { + self.state = match d { + WidgetDrag::RotateAll(d) => State::RotateAll(d), + WidgetDrag::Space(d) => State::Space(d), + WidgetDrag::MoveAll(d) => State::MoveAll(d), + WidgetDrag::Member(d) => State::Member(d), + }; return Some(vec![]); } if let Some(b) = BracketDrag::hit(cx, ev) { diff --git a/crates/trace/Cargo.toml b/crates/trace/Cargo.toml index 83f7425c05..a605a69adb 100644 --- a/crates/trace/Cargo.toml +++ b/crates/trace/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } kurbo = { workspace = true } -craft-image-codecs = { workspace = true } +image = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs index 3949adf96a..52e80a368d 100644 --- a/crates/trace/src/opaque.rs +++ b/crates/trace/src/opaque.rs @@ -24,49 +24,26 @@ pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20; /// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`], /// gives [`TraceError::TooLarge`] before it is decoded. pub fn decode_alpha(bytes: &[u8]) -> Result)>, TraceError> { - use craft_image_codecs::{ChannelLayout, DecodeOptions, Limits, RasterColorType, SampleType, decode_with, inspect_raster}; - let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string()); - if let Some(info) = inspect_raster(bytes).map_err(decode_err)? { - if info.width == 0 || info.height == 0 { - return Err(TraceError::Empty); - } - let might_have_alpha = match info.color_type { - RasterColorType::GrayAlpha | RasterColorType::Rgba | RasterColorType::CmykAlpha => true, - RasterColorType::Rgb => info.channels > 3, - RasterColorType::Gray => info.channels > 1, - RasterColorType::Indexed => true, - _ => false, - }; - if !might_have_alpha { - return Ok(None); - } - let pixels = u64::from(info.width) * u64::from(info.height); - let frame = gif_first_frame_pixels(bytes).unwrap_or(0); - let total_bytes = pixels - .saturating_mul(u64::from(info.channels)) - .saturating_mul(u64::from(info.bits_per_sample.max(8).div_ceil(8))); - if pixels.max(frame) > MAX_PIXELS || total_bytes > ALPHA_MAX_DECODE_BYTES { - return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 }); - } - } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { - return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); - } - let opts = DecodeOptions { - limits: Limits { max_pixels: MAX_PIXELS, max_alloc: ALPHA_MAX_DECODE_BYTES, ..Limits::default() }, - ..DecodeOptions::default() - }; - let img = decode_with(bytes, &opts).map_err(decode_err)?; - let (w, h) = (img.width(), img.height()); + use image::ImageDecoder; + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let decoder = reader.into_decoder().map_err(decode_err)?; + let (w, h) = decoder.dimensions(); if w == 0 || h == 0 { return Err(TraceError::Empty); } - if !img.layout().has_alpha() { + if !decoder.color_type().has_alpha() { return Ok(None); } - let alpha: Vec = match (img.layout(), img.sample_type()) { - (ChannelLayout::Rgba, SampleType::U8) => img.data().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), - (ChannelLayout::GrayA, SampleType::U8) => img.data().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), - _ => img.to_rgba8().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + let pixels = u64::from(w) * u64::from(h); + let frame = gif_first_frame_pixels(bytes).unwrap_or(0); + if pixels.max(frame) > MAX_PIXELS || decoder.total_bytes() > ALPHA_MAX_DECODE_BYTES { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let alpha: Vec = match image::DynamicImage::from_decoder(decoder).map_err(decode_err)? { + image::DynamicImage::ImageRgba8(img) => img.as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + image::DynamicImage::ImageLumaA8(img) => img.as_raw().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), + img => img.to_rgba8().as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), }; Ok(Some((w, h, alpha))) } @@ -288,41 +265,28 @@ mod tests { } /// `img` encoded as `format`. - fn encoded(img: &craft_image_codecs::Image, format: craft_image_codecs::Format) -> Vec { - craft_image_codecs::encode(img, format, &Default::default()).unwrap() + fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out } #[test] fn decode_alpha_reads_the_alpha_of_formats_that_have_one() { - use craft_image_codecs::{ChannelLayout, Format, Image, SampleType}; - let mut rgba_bytes = Vec::with_capacity(4 * 4 * 4); - let mut luma_a_bytes = Vec::with_capacity(4 * 4 * 2); - let mut rgba16_bytes = Vec::with_capacity(4 * 4 * 8); - let mut rgb_bytes = Vec::with_capacity(4 * 4 * 3); - let mut want = Vec::with_capacity(16); - for y in 0..4u32 { - for x in 0..4u32 { - let a = u8::try_from((x + 4 * y) * 16).unwrap(); - want.push(a); - rgba_bytes.extend_from_slice(&[10, 20, 30, a]); - luma_a_bytes.extend_from_slice(&[18, a]); - let a16 = u16::from(a) * 257; - for v in [10u16 * 257, 20 * 257, 30 * 257, a16] { - rgba16_bytes.extend_from_slice(&v.to_ne_bytes()); - } - rgb_bytes.extend_from_slice(&[10, 20, 30]); - } - } - let rgba = Image::from_u8(4, 4, ChannelLayout::Rgba, rgba_bytes).unwrap(); - let luma_a = Image::from_u8(4, 4, ChannelLayout::GrayA, luma_a_bytes).unwrap(); - let rgba16 = Image::new(4, 4, ChannelLayout::Rgba, SampleType::U16, rgba16_bytes).unwrap(); - for (name, img) in [("rgba", rgba), ("gray and alpha", luma_a), ("16 bits", rgba16)] { - let got = decode_alpha(&encoded(&img, Format::Png)).unwrap(); + let px = |x: u32, y: u32| image::Rgba([10, 20, 30, u8::try_from((x + 4 * y) * 16).unwrap()]); + let rgba = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, px)); + let want: Vec = (0..16).map(|i| u8::try_from(i * 16).unwrap()).collect(); + for (name, img) in [ + ("rgba", rgba.clone()), + ("gray and alpha", image::DynamicImage::ImageLumaA8(rgba.to_luma_alpha8())), + ("16 bits", image::DynamicImage::ImageRgba16(rgba.to_rgba16())), + ] { + let got = decode_alpha(&encoded(img, image::ImageFormat::Png)).unwrap(); assert_eq!(got, Some((4, 4, want.clone())), "{name}"); } - let rgb = Image::from_u8(4, 4, ChannelLayout::Rgb, rgb_bytes).unwrap(); - assert_eq!(decode_alpha(&encoded(&rgb, Format::Png)).unwrap(), None); - assert_eq!(decode_alpha(&encoded(&rgb, Format::Jpeg)).unwrap(), None); + let rgb = image::DynamicImage::ImageRgb8(rgba.to_rgb8()); + assert_eq!(decode_alpha(&encoded(rgb.clone(), image::ImageFormat::Png)).unwrap(), None); + assert_eq!(decode_alpha(&encoded(rgb, image::ImageFormat::Jpeg)).unwrap(), None); assert!(decode_alpha(b"not an image").is_err()); } } diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index f5a2d9fa47..9a7031256a 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -1,7 +1,7 @@ //! Colour reduction and noise removal. Colours are compared in Oklab, where equal distances look //! equally different. -use crate::{Mode, Palette, Raster, TraceParams}; +use crate::{Mode, Palette, Raster, TraceParams, parallel_map}; mod cut_edge; mod denoise; @@ -191,6 +191,11 @@ fn own_colours(img: &Raster) -> Vec { /// [`own_colours`] among the pixels that `among` marks: the others are neither own colours nor /// the neighbours a pixel is a blend of. fn own_colours_among(img: &Raster, among: &[bool]) -> Vec { + own_colours_among_by(img, among, PIXELS_PER_SHARE) +} + +/// [`own_colours_among`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]). +fn own_colours_among_by(img: &Raster, among: &[bool], per_share: usize) -> Vec { let (w, h) = (img.width as isize, img.height as isize); let px = img.rgba.as_chunks::<4>().0; let at = |x: isize, y: isize| { @@ -198,28 +203,52 @@ fn own_colours_among(img: &Raster, among: &[bool]) -> Vec { let p = px.get(i).filter(|_| among.get(i).copied().unwrap_or(false))?; Some([f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]) }; - let mut out = Vec::with_capacity(px.len()); - for y in 0..h { - for x in 0..w { - let Some(p) = at(x, y) else { - out.push(false); - continue; - }; - let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| { - let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false }; - let d = [0, 1, 2].map(|k| b[k] - a[k]); - let len2: f64 = d.iter().map(|v| v * v).sum(); - if len2 <= 400.0 { - return false; - } - let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::() / len2; - let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum(); - t > 0.06 && t < 0.94 && off2 < len2 / 16.0 - }); - out.push(!blend); + over_rows(img.width as usize, img.height as usize, per_share, |rows| { + let mut out = Vec::with_capacity(rows.len() * img.width as usize); + for y in rows.start as isize..rows.end as isize { + for x in 0..w { + let Some(p) = at(x, y) else { + out.push(false); + continue; + }; + let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| { + let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false }; + let d = [0, 1, 2].map(|k| b[k] - a[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + if len2 <= 400.0 { + return false; + } + let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::() / len2; + let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum(); + t > 0.06 && t < 0.94 && off2 < len2 / 16.0 + }); + out.push(!blend); + } } + out + }) +} + +/// About how many pixels one thread works through at a time in the passes over every pixel that +/// [`over_rows`] spreads over the available cores. A picture of fewer pixels is worked through on +/// one thread. +const PIXELS_PER_SHARE: usize = 1 << 16; + +/// `f` of each band of rows of a picture `w` pixels wide and `h` rows high, each band about +/// `per_share` pixels (at least one row), spread over the available cores (one thread on the web), +/// and the results joined in row order. Each band's result depends only on its rows, so the +/// joined result is the same however many threads there are. A picture with no pixels gives no +/// result. +fn over_rows(w: usize, h: usize, per_share: usize, f: impl Fn(std::ops::Range) -> Vec + Sync) -> Vec { + if w == 0 || h == 0 { + return Vec::new(); } - out + let rows = (per_share / w.max(1)).max(1); + let bands: Vec> = (0..h).step_by(rows).map(|y| y..(y + rows).min(h)).collect(); + if bands.len() <= 1 { + return bands.into_iter().flat_map(&f).collect(); + } + parallel_map(&bands, |rows| f(rows.clone())).into_iter().flatten().collect() } /// Whether the palette is made from the picture's own colours alone (`own` of `all` opaque @@ -325,3 +354,40 @@ fn merge_mixed(q: Quantized, w: usize, h: usize, keep: &[[u8; 3]]) -> Quantized let labels = q.labels.iter().map(|&l| into.get(l as usize).map_or(l, |&t| t as u16)).collect(); compact(labels, color.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blend_pixels_are_found_the_same_however_the_picture_is_shared_out() { + // Slanted stripes of red and blue, blended over three pixels at each edge, with a little + // noise and a band of half-transparent pixels along the bottom. Odd sizes, so that the last + // band of rows is short. + let (w, h) = (173u32, 97u32); + let (red, blue) = ([200.0, 30.0, 40.0], [20.0, 60.0, 180.0]); + let img = Raster::from_fn(w, h, |x, y| { + let s = (x + y / 2) % 24; + let t = f64::from(s.saturating_sub(9).min(3)) / 3.0 - f64::from(s.saturating_sub(21).min(3)) / 3.0; + let noise = f64::from((x * 7 + y * 13) % 5) - 2.0; + let c = [0, 1, 2].map(|k| (red[k] + t * (blue[k] - red[k]) + noise).clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }] + }); + let all = w as usize * h as usize; + let opaque: Vec = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect(); + // One row per share, a few rows per share, and the whole picture as one. + let own: Vec> = [1, 3 * w as usize + 5, all].iter().map(|&s| own_colours_among_by(&img, &opaque, s)).collect(); + assert_eq!(own[0].len(), all); + assert!(own[0].iter().any(|&o| !o), "the edges have blend pixels"); + assert!(own.iter().all(|o| *o == own[0])); + } + + #[test] + fn a_picture_with_no_pixels_is_not_shared_out() { + // No band for each of the rows of a picture with no columns, however many rows it has. + assert!(over_rows(0, usize::MAX, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty()); + assert!(over_rows(5, 0, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty()); + let img = Raster::new(0, u32::MAX, vec![]).unwrap(); + assert!(own_colours_among(&img, &[]).is_empty()); + } +} diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 27b7727b38..265c0d9b2c 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -1,5 +1,6 @@ use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab}; -use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab}; +use super::{Mode, PIXELS_PER_SHARE, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab, over_rows}; +use crate::parallel_map; /// `q` with its blend pixels settled, and the colours that leaves unused dropped. /// @@ -10,59 +11,136 @@ use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, ok /// own pixels within two pixels of it takes the side it is nearer to of the pair of those colours /// it is best a blend of, when that explains it twice as well as its own colour does. With one /// colour around it, it keeps its own. -pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized { +pub(super) fn settled(q: Quantized, img: &Raster, own: &[bool]) -> Quantized { + settled_by(q, img, own, PIXELS_PER_SHARE) +} + +/// [`settled`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]). +fn settled_by(mut q: Quantized, img: &Raster, own: &[bool], per_share: usize) -> Quantized { let (w, h) = (img.width as usize, img.height as usize); if q.labels.len() != w * h || own.len() != w * h { return q; } - let before = q.labels.clone(); + // Each pixel is settled by the labels as they were before any label changed. Each band of rows + // keeps its changes (see [`Changes`]), and they are made once every band is done. + let before = &q.labels; let color = |l: u16| q.palette.get(l as usize).map(|c| c.map(f64::from)); let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::().sqrt(); - let mut changed = false; - for i in 0..w * h { - let mine_l = before[i]; - if own[i] || mine_l == TRANSPARENT { - continue; - } - let (x, y) = (i % w, i / w); - // The colours of the picture's own pixels within two pixels (seldom more than three). - let mut near: Vec = Vec::new(); - for yy in y.saturating_sub(2)..=(y + 2).min(h - 1) { - for xx in x.saturating_sub(2)..=(x + 2).min(w - 1) { - let j = yy * w + xx; - if own[j] && before[j] != TRANSPARENT && !near.contains(&before[j]) { - near.push(before[j]); + let bands: Vec<(usize, Changes)> = over_rows(w, h, per_share, |rows| { + let (first, end) = (rows.start * w, rows.end * w); + let mut band = Changes::new(end - first); + for i in first..end { + let mine_l = before[i]; + if own[i] || mine_l == TRANSPARENT { + continue; + } + let (x, y) = (i % w, i / w); + // The colours of the picture's own pixels within two pixels (seldom more than three). + let mut near: Vec = Vec::new(); + for yy in y.saturating_sub(2)..=(y + 2).min(h - 1) { + for xx in x.saturating_sub(2)..=(x + 2).min(w - 1) { + let j = yy * w + xx; + if own[j] && before[j] != TRANSPARENT && !near.contains(&before[j]) { + near.push(before[j]); + } } } - } - let (Some(p), Some(mine)) = (img.rgba.get(i * 4..i * 4 + 3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]), color(mine_l)) - else { - continue; - }; - if near.len() < 2 || near.contains(&mine_l) { - continue; - } - let mut best: Option<(f64, u16)> = None; - for (n, &a) in near.iter().enumerate() { - for &b in &near[n + 1..] { - let (Some(ca), Some(cb)) = (color(a), color(b)) else { continue }; - let d = [0, 1, 2].map(|k| cb[k] - ca[k]); - let len2: f64 = d.iter().map(|v| v * v).sum(); - let t = if len2 > 0.0 { ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::() / len2).clamp(0.0, 1.0) } else { 0.0 }; - let off = dist(p, [0, 1, 2].map(|k| ca[k] + t * d[k])); - if best.is_none_or(|x| off < x.0) { - best = Some((off, if t < 0.5 { a } else { b })); + let (Some(p), Some(mine)) = (img.rgba.get(i * 4..i * 4 + 3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]), color(mine_l)) + else { + continue; + }; + if near.len() < 2 || near.contains(&mine_l) { + continue; + } + let mut best: Option<(f64, u16)> = None; + for (n, &a) in near.iter().enumerate() { + for &b in &near[n + 1..] { + let (Some(ca), Some(cb)) = (color(a), color(b)) else { continue }; + let d = [0, 1, 2].map(|k| cb[k] - ca[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + let t = if len2 > 0.0 { ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::() / len2).clamp(0.0, 1.0) } else { 0.0 }; + let off = dist(p, [0, 1, 2].map(|k| ca[k] + t * d[k])); + if best.is_none_or(|x| off < x.0) { + best = Some((off, if t < 0.5 { a } else { b })); + } } } + if let Some((off, to)) = best + && off < 0.5 * dist(p, mine) + { + band.set(i - first, to, || before.get(first..end).unwrap_or_default().to_vec()); + } } - if let Some((off, to)) = best - && off < 0.5 * dist(p, mine) - { - q.labels[i] = to; - changed = true; + if band.is_empty() { vec![] } else { vec![(first, band)] } + }); + if bands.is_empty() { + return q; + } + for (first, band) in bands { + band.each(|at, to| { + if let Some(l) = q.labels.get_mut(first + at) { + *l = to; + } + }); + } + compact(q.labels, q.palette) +} + +/// The labels a pass changes in one part of a picture, `len` pixels long, counting each pixel from +/// the part's first. They are kept as pairs of a pixel and its new label until the pairs would take +/// more memory than a copy of the part's labels, and as that copy, with the changes made, after +/// that. The pairs take at most as much memory as the copy, and a part holds both only while it +/// moves the pairs into the copy. +struct Changes { + len: usize, + few: Vec<(usize, u16)>, + copy: Option>, +} + +impl Changes { + fn new(len: usize) -> Self { + Changes { len, few: vec![], copy: None } + } + + /// Changes the label of the pixel `at` to `to`. `labels` gives the part's labels as they were + /// before any changed, for the copy. + fn set(&mut self, at: usize, to: u16, labels: impl FnOnce() -> Vec) { + if let Some(copy) = &mut self.copy { + if let Some(l) = copy.get_mut(at) { + *l = to; + } + return; + } + // The most pairs that take no more memory than a copy of the part's labels. The pairs grow + // by doubling up to that many, and no further. + let most = self.len * size_of::() / size_of::<(usize, u16)>(); + if self.few.len() < most { + if self.few.len() == self.few.capacity() { + self.few.reserve_exact(self.few.capacity().max(4).min(most - self.few.len())); + } + self.few.push((at, to)); + return; + } + let mut copy = labels(); + for (at, to) in std::mem::take(&mut self.few).into_iter().chain([(at, to)]) { + if let Some(l) = copy.get_mut(at) { + *l = to; + } + } + self.copy = Some(copy); + } + + fn is_empty(&self) -> bool { + self.few.is_empty() && self.copy.is_none() + } + + /// `f` of each changed pixel and its new label (of every pixel of the part, once it is a copy). + fn each(self, mut f: impl FnMut(usize, u16)) { + match self.copy { + Some(copy) => copy.into_iter().enumerate().for_each(|(at, to)| f(at, to)), + None => self.few.into_iter().for_each(|(at, to)| f(at, to)), } } - if changed { compact(q.labels, q.palette) } else { q } } /// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels @@ -122,12 +200,47 @@ const ON_THE_LINE: f64 = 0.15; /// their distance apart. Only the [`NEAR_COLORS`] colors nearest to its color are paired. It keeps /// its own color when that color is one of the pair, or when one color is all there is around it. /// With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]). -pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized { +pub(super) fn settled_wide(q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized { + settled_wide_by(q, img, own, gray, PIXELS_PER_SHARE) +} + +/// [`settled_wide`], with its strips of columns worked on in groups of about `per_share` pixels +/// (at least one strip), spread over the available cores (one thread on the web). +fn settled_wide_by(mut q: Quantized, img: &Raster, own: &[bool], gray: bool, per_share: usize) -> Quantized { let (w, h) = (img.width as usize, img.height as usize); - if q.labels.len() != w * h || own.len() != w * h { + if q.labels.len() != w * h || own.len() != w * h || w == 0 || h == 0 { return q; } - let before = q.labels.clone(); + // Each pixel is settled by the labels as they were before any label changed. Each group of + // strips keeps its changes (see [`Changes`]), and they are made once every group is done. + let strip = BLEND_STRIP.min(w).max(1); + let per_group = (per_share / (strip * h).max(1)).max(1); + let cols = strip.saturating_mul(per_group); + let groups: Vec> = (0..w).step_by(cols).map(|x| x..x.saturating_add(cols).min(w)).collect(); + let settle = |cols: &std::ops::Range| (cols.clone(), settled_wide_strips(&q, img, own, gray, cols.clone())); + let mut settled: Vec<(std::ops::Range, Changes)> = + if groups.len() <= 1 { groups.iter().map(settle).collect() } else { parallel_map(&groups, settle) }; + settled.retain(|(_, changes)| !changes.is_empty()); + if settled.is_empty() { + return q; + } + for (cols, group) in settled { + let gw = cols.len().max(1); + group.each(|at, to| { + if let Some(l) = q.labels.get_mut((at / gw) * w + cols.start + at % gw) { + *l = to; + } + }); + } + compact(q.labels, q.palette) +} + +/// The labels that [`settled_wide`] changes in the columns `cols` (whole strips of [`BLEND_STRIP`] +/// columns, or the picture's last columns), each pixel counted row by row across those columns. +fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, cols: std::ops::Range) -> Changes { + let (w, h) = (img.width as usize, img.height as usize); + let gw = cols.len(); + let before = &q.labels; let colors: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect(); let dist2 = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::(); // The colors of the own pixels within reach of each pixel, as bits (one word per 64 palette @@ -135,7 +248,9 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b // sliding along each row, kept for the last 2 × reach + 1 rows), then across those rows. let words = q.palette.len().div_ceil(64).max(1); let span = 2 * BLEND_REACH + 1; - let strip = BLEND_STRIP.min(w); + let strip = BLEND_STRIP.min(w).max(1); + let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] }; + let mut settled = Changes::new(gw * h); let mut rows = vec![0u64; span * strip * words]; let mut counts = vec![0u32; q.palette.len()]; let mut window = vec![0u64; words]; @@ -178,12 +293,10 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b } } }; - let mut changed = false; let mut near: Vec = Vec::new(); let mut by_distance: Vec<(f64, u16)> = Vec::new(); - let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] }; - for (x0, y) in (0..w).step_by(strip.max(1)).flat_map(|x0| (0..h).map(move |y| (x0, y))) { - let x1 = (x0 + strip).min(w); + for (x0, y) in cols.clone().step_by(strip).flat_map(|x0| (0..h).map(move |y| (x0, y))) { + let x1 = (x0 + strip).min(cols.end); if y == 0 { for yy in 0..BLEND_REACH.min(h) { row_colors(yy, x0, x1, &mut rows); @@ -265,12 +378,13 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b && !pair.contains(&mine_l) && (off2 < BETTER_FIT * dist2(p, mine) || (between && off2 < ON_THE_LINE * ON_THE_LINE * apart2)) { - q.labels[i] = to; - changed = true; + settled.set(y * gw + x - cols.start, to, || { + (0..h).flat_map(|yy| before.get(yy * w + cols.start..yy * w + cols.end).unwrap_or_default()).copied().collect() + }); } } } - if changed { compact(q.labels, q.palette) } else { q } + settled } /// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy @@ -660,6 +774,109 @@ fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool { mod tests { use super::*; + /// Rings of four colors around a point off the center, blended over three pixels at each edge, + /// with a little noise, and a band of half-transparent pixels along the bottom. + fn rings(w: u32, h: u32) -> Raster { + let colors = [[200.0, 30.0, 40.0], [20.0, 60.0, 180.0], [240.0, 210.0, 50.0], [25.0, 25.0, 25.0]]; + Raster::from_fn(w, h, |x, y| { + let r = (f64::from(x) - 0.4 * f64::from(w)).hypot(f64::from(y) - 0.55 * f64::from(h)) / 9.0; + let (ring, t) = (r.floor() as usize, ((r.fract() - 0.8) / 0.2).clamp(0.0, 1.0)); + let (a, b) = (colors[ring % 4], colors[(ring + 1) % 4]); + let noise = f64::from((x * 7 + y * 13) % 5) - 2.0; + let c = [0, 1, 2].map(|k| (a[k] + t * (b[k] - a[k]) + noise).clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }] + }) + } + + #[test] + fn changes_are_copied_out_once_their_pairs_would_take_more_memory_than_the_labels() { + let labels: Vec = (0..64).collect(); + let pairs = labels.len() * size_of::() / size_of::<(usize, u16)>(); + let mut changes = Changes::new(labels.len()); + for at in 0..pairs { + changes.set(at * 3, 99, || labels.clone()); + } + assert!(changes.copy.is_none()); + assert!(changes.few.capacity() <= pairs, "the pairs take no more memory than a copy of the labels"); + changes.set(63, 98, || labels.clone()); + assert!(changes.few.is_empty() && changes.copy.is_some()); + changes.set(1, 97, || labels.clone()); + let mut out = labels.clone(); + changes.each(|at, to| out[at] = to); + let mut expected = labels.clone(); + (0..pairs).for_each(|at| expected[at * 3] = 99); + (expected[63], expected[1]) = (98, 97); + assert_eq!(out, expected); + } + + #[test] + fn blend_pixels_settle_the_same_however_the_picture_is_shared_out() { + // Odd sizes, so that the last band of rows and the last strip of columns are short. + let img = rings(173, 97); + let all = img.width as usize * img.height as usize; + let opaque: Vec = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect(); + let own = super::super::own_colours_among(&img, &opaque); + // Two colors for each blend, and an olive that the blends lie nearer to than to their sides, + // each pixel labeled with the nearest of them (none for a transparent one). + let palette = vec![[200, 30, 40], [20, 60, 180], [240, 210, 50], [25, 25, 25], [120, 120, 60]]; + let labels = img + .rgba + .as_chunks::<4>() + .0 + .iter() + .map(|p| { + let d = |c: &[u8; 3]| (0..3).map(|k| (i32::from(p[k]) - i32::from(c[k])).pow(2)).sum::(); + (0..palette.len()).min_by_key(|&i| d(&palette[i])).map_or(TRANSPARENT, |i| i as u16) + }) + .collect(); + let q = Quantized { labels, palette }; + // One row (or one strip) per share, a few rows per share, and the whole picture as one. + let shares = [1, 3 * 173 + 5, all]; + for (name, settle) in [ + ("settled", &(|s| settled_by(q.clone(), &img, &own, s)) as &dyn Fn(usize) -> Quantized), + ("settled_wide", &|s| settled_wide_by(q.clone(), &img, &own, false, s)), + ("settled_wide gray", &|s| settled_wide_by(q.clone(), &img, &own, true, s)), + ] { + let out: Vec = shares.iter().map(|&s| settle(s)).collect(); + assert_ne!(out[0], q, "{name} settles some blend pixels"); + assert!(out.iter().all(|o| *o == out[0]), "{name}"); + } + } + + #[test] + fn blend_pixels_settle_the_same_when_most_of_a_share_changes() { + // Columns of red and blue own pixels, two blend pixels between each pair: one nearer to red + // and one nearer to blue, both labeled with an olive. Half the pixels of each band of rows and + // of each group of strips change, more than the eighth at which a share's changes are copied + // out (see [`Changes`]). 44 columns, so that the last strip of 7 is short. + let (w, h) = (44u32, 5u32); + let (red, blue) = ([200u8, 30, 40], [20u8, 60, 180]); + let mix = |t: f64| [0, 1, 2].map(|k| (f64::from(red[k]) + t * (f64::from(blue[k]) - f64::from(red[k]))).round() as u8); + let img = Raster::from_fn(w, h, |x, _| { + let c = [red, mix(0.3), mix(0.7), blue][x as usize % 4]; + [c[0], c[1], c[2], 255] + }); + let own: Vec = (0..w * h).map(|i| matches!(i % w % 4, 0 | 3)).collect(); + let labels = (0..w * h).map(|i| [0, 2, 2, 1][(i % w % 4) as usize]).collect(); + let q = Quantized { labels, palette: vec![red, blue, [120, 120, 60]] }; + let expected = Quantized { labels: (0..w * h).map(|i| [0, 0, 1, 1][(i % w % 4) as usize]).collect(), palette: vec![red, blue] }; + let all = (w * h) as usize; + for s in [1, 3 * 7 * h as usize, all] { + assert_eq!(settled_by(q.clone(), &img, &own, s), expected, "settled, {s} pixels per share"); + assert_eq!(settled_wide_by(q.clone(), &img, &own, false, s), expected, "settled_wide, {s} pixels per share"); + } + } + + #[test] + fn a_picture_with_no_pixels_settles_to_itself() { + let q = Quantized { labels: vec![], palette: vec![[0, 0, 0], [255, 255, 255]] }; + for (w, h) in [(u32::MAX, 0), (0, u32::MAX)] { + let img = Raster::new(w, h, vec![]).unwrap(); + assert_eq!(settled_by(q.clone(), &img, &[], PIXELS_PER_SHARE), q, "settled, {w} x {h}"); + assert_eq!(settled_wide_by(q.clone(), &img, &[], false, PIXELS_PER_SHARE), q, "settled_wide, {w} x {h}"); + } + } + #[test] fn a_gray_blend_takes_the_closer_of_two_pairs_it_lies_between_despite_rounding() { // Gray 105 in the middle, with own pixels of gray 50 to its left, 200 to its right and black diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 0fdce8321d..51312c685a 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -158,100 +158,62 @@ impl Raster { } /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). pub fn decode(bytes: &[u8]) -> Result { - if let Some(info) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? { - if info.width == 0 || info.height == 0 { - return Err(TraceError::Empty); - } - if u64::from(info.width) * u64::from(info.height) > MAX_PIXELS - || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) - { - return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 }); - } - } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { - return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); - } - let opts = craft_image_codecs::DecodeOptions { - limits: craft_image_codecs::Limits { - max_pixels: MAX_PIXELS, - ..craft_image_codecs::Limits::default() - }, - ..craft_image_codecs::DecodeOptions::default() - }; - let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = (img.width(), img.height()); + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; if w == 0 || h == 0 { return Err(TraceError::Empty); } - Ok(Self { width: w, height: h, rgba: img.to_rgba8() }) + if u64::from(w) * u64::from(h) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); + let (w, h) = img.dimensions(); + Ok(Self { width: w, height: h, rgba: img.into_raw() }) } /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly /// less than its own resolution. The caller maps the result back with `width` / `height`. pub fn decode_to_fit(bytes: &[u8]) -> Result { - if let Some(info) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? { - if info.width == 0 || info.height == 0 { - return Err(TraceError::Empty); - } - let pixels = u64::from(info.width) * u64::from(info.height); - if pixels <= MAX_PIXELS && !gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { - return Self::decode(bytes); - } - if pixels > MAX_SOURCE_PIXELS { - return Err(TraceError::TooLarge { - width: info.width, - height: info.height, - max: MAX_SOURCE_PIXELS >> 20, - }); - } - if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { - return Err(TraceError::TooLarge { - width: info.width, - height: info.height, - max: MAX_SOURCE_PIXELS >> 20, - }); - } - } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { - return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_SOURCE_PIXELS >> 20 }); - } - // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. - // A deeper image (16 bits a channel) that needs more fails with a decode error. - let opts = craft_image_codecs::DecodeOptions { - limits: craft_image_codecs::Limits { - max_pixels: MAX_SOURCE_PIXELS, - max_alloc: if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }, - ..craft_image_codecs::Limits::default() - }, - ..craft_image_codecs::DecodeOptions::default() - }; - let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = (img.width(), img.height()); + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; if w == 0 || h == 0 { return Err(TraceError::Empty); } - let rgba = img.to_rgba8(); - if u64::from(w) * u64::from(h) <= MAX_PIXELS { - return Ok(Self { width: w, height: h, rgba }); + let pixels = u64::from(w) * u64::from(h); + if pixels <= MAX_PIXELS { + return Self::decode(bytes); } - Ok(Self::fitted(&Self { width: w, height: h, rgba }, MAX_PIXELS)) + if pixels > MAX_SOURCE_PIXELS { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + } + // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. + // A deeper image (16 bits a channel) that needs more fails with a decode error. + if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + 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); + 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: &Self, max: u64) -> Self { - let (nw, nh) = fit_size(img.width, img.height, max); + pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { + let (w, h) = img.dimensions(); + let (nw, nh) = fit_size(w, h, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - Self { width: nw, height: nh, rgba: box_reduced(&img.rgba, img.width, img.height, nw, nh) } + Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec { - let Ok(img) = craft_image_codecs::Image::from_u8( - self.width, - self.height, - craft_image_codecs::ChannelLayout::Rgba, - self.rgba.clone(), - ) else { - return Vec::new(); - }; - craft_image_codecs::encode(&img, craft_image_codecs::Format::Png, &Default::default()).unwrap_or_default() + let mut out = Vec::new(); + if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { + let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png); + } + out } /// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose /// fields were set to a size its bytes don't have). diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index f2d4760a4d..80eb322bbc 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1094,7 +1094,7 @@ fn scaling_down_weighs_color_by_alpha() { // 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 = Raster::decode(&half_clear.encode_png()).unwrap(); + 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()); @@ -2138,18 +2138,10 @@ fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() { /// `img` saved as a JPEG of `quality` and read back. fn through_jpeg(img: &Raster, quality: u8) -> Raster { let rgb: Vec = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); - let encoded = craft_image_codecs::Image::from_u8( - img.width, - img.height, - craft_image_codecs::ChannelLayout::Rgb, - rgb, - ) - .unwrap(); - let opts = craft_image_codecs::EncodeOptions { - jpeg_quality: quality, - ..Default::default() - }; - let bytes = craft_image_codecs::encode(&encoded, craft_image_codecs::Format::Jpeg, &opts).unwrap(); + let mut bytes = vec![]; + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality) + .encode(&rgb, img.width, img.height, image::ExtendedColorType::Rgb8) + .unwrap(); Raster::decode(&bytes).unwrap() } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 486c70de88..5c30f96246 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -15,6 +15,7 @@ use crate::stylus::StylusFeed; use crate::theme::{self, Tokens}; use crate::{CacheKey, VectorcraftApp, now_ms, widgets}; +mod alternates_bar; mod artboards; mod context; mod contour_bar; @@ -441,6 +442,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if app.ui.screen_mode < 3 { contour_bar::contour_bar(app, ui, &xf); + // After the task bar, which the row of character alternates keeps clear of. + alternates_bar::alternates_bar(app, ui, &xf); } if app.ui.screen_mode < 3 { crate::free_transform::show(app, ui, xf.rect); diff --git a/crates/ui-egui/src/canvas/alternates_bar.rs b/crates/ui-egui/src/canvas/alternates_bar.rs new file mode 100644 index 0000000000..83914ef11d --- /dev/null +++ b/crates/ui-egui/src/canvas/alternates_bar.rs @@ -0,0 +1,163 @@ +//! Character alternates on the canvas: with a Type tool and exactly one character selected, a +//! small row of that character's alternate glyphs shows beside it. A click on a glyph draws the +//! character with it, as a double-click on the glyph in the Glyphs panel's Show › Alternates for +//! Current Selection does, through the same command and so as one undo step. An arrow at the end +//! of the row opens the Glyphs panel when the character has more alternates than the row shows. +//! Preferences › Type › Show Character Alternates (`showCharacterAlternates`) turns the row off. +//! +//! Public behavior: selecting a character shows its alternate glyphs next to it, a click replaces +//! the character's glyph, at most five alternates show, and a control to their right opens the +//! Glyphs panel for the rest ("Replace a character with on-canvas alternate glyphs", +//! ; +//! [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)). +//! The row's place, its size and the preference are VectorCraft's own. + +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_geom::Point; +use vectorcraft_text::FontDb; + +use super::{CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Tokens, Ui, Vec2, VectorcraftApp, Xf, pos2, vec2}; +use crate::panels::glyphs; + +/// The side of one glyph cell, in points. +const CELL: f32 = 28.0; +/// The width of the arrow that opens the Glyphs panel. +const MORE: f32 = 16.0; +/// The space between the row's edge and its cells: the frame's 1-point line and 3 points inside it. +const PAD: f32 = 4.0; +/// The space between the selected character and the row. +const GAP: f32 = 6.0; + +fn bar_id() -> egui::Id { + egui::Id::unique("character-alternates-bar") +} + +/// Where the row was last drawn (none while it isn't shown). +#[cfg(test)] +pub(super) fn bar_rect(ctx: &egui::Context) -> Option { + let id = bar_id(); + ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) +} + +/// The size of a row of `count` glyphs, with the arrow when `more`. +fn bar_size(count: usize, more: bool) -> Vec2 { + let cells = count as f32 * CELL + if more { MORE } else { 0.0 }; + vec2(cells + 2.0 * PAD, CELL + 2.0 * PAD) +} + +/// The document-space highlight quads of bytes `a..b` of text `id`, and whether the text is set +/// vertically. Kept until the document or the selection changes, so the text is laid out once for +/// each, not in every frame. Empty for a character the text does not draw (overset area type). +fn character_quads(app: &VectorcraftApp, ui: &Ui, id: NodeId, (a, b): (usize, usize)) -> Option<(Vec<[Point; 4]>, bool)> { + type Quads = (Vec<[Point; 4]>, bool); + let st = app.session.active()?; + let key = (st.uid, st.revision, id, a, b); + let slot = bar_id().with("quads"); + if let Some((cached_key, quads)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize, usize), Quads)>(slot)) + && cached_key == key + { + return Some(quads); + } + let NodeKind::Text(tx) = &st.doc.node(id)?.kind else { return None }; + let lay = vectorcraft_text::layout(FontDb::global(), tx); + let quads: Vec<[Point; 4]> = vectorcraft_text::selection_quads(&lay, a, b).into_iter().map(|q| q.map(|c| tx.xf * c)).collect(); + let found = (quads, tx.vertical); + ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone()))); + Some(found) +} + +/// The top-left corner of a row of `size` beside the selected character's screen box `sel`, inside +/// `area`. The row goes under a character of horizontal type, or over it when there is no room +/// under it; beside a character of vertical type, on its right, or on its left when there is no +/// room on the right. A side is also given up when the row would cover `avoid` there (the +/// Contextual Task Bar). With no room on either side the row takes the first one, moved into +/// `area`. +fn place(sel: egui::Rect, size: Vec2, area: egui::Rect, vertical: bool, avoid: Option) -> Pos2 { + let sides = if vertical { + [pos2(sel.right() + GAP, sel.center().y - size.y / 2.0), pos2(sel.left() - GAP - size.x, sel.center().y - size.y / 2.0)] + } else { + [pos2(sel.center().x - size.x / 2.0, sel.bottom() + GAP), pos2(sel.center().x - size.x / 2.0, sel.top() - GAP - size.y)] + }; + let last = (area.max - size).max(area.min); + // Along the character's line the row may slide to stay on the canvas; across it, it may not. + let slide = |at: Pos2| if vertical { pos2(at.x, at.y.clamp(area.min.y, last.y)) } else { pos2(at.x.clamp(area.min.x, last.x), at.y) }; + let fits = |at: Pos2| { + let r = egui::Rect::from_min_size(at, size); + area.contains_rect(r) && avoid.is_none_or(|bar| !bar.intersects(r)) + }; + let sides = sides.map(slide); + sides.into_iter().find(|at| fits(*at)).unwrap_or_else(|| sides[0].clamp(area.min, last)) +} + +/// The row of alternate glyphs beside the Type tool's one selected character. +pub(super) fn alternates_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { + // Not while a press or a drag is choosing the selection: the row would follow the pointer. + if !app.session.prefs.show_character_alternates || !app.session.tool_wants_text() || app.session.tool_busy() { + return; + } + let Some(found) = glyphs::on_canvas(app, ui) else { return }; + let Some((quads, vertical)) = character_quads(app, ui, found.id, found.bytes) else { return }; + let corners: Vec = quads.iter().flatten().map(|p| xf.to_screen(*p)).collect(); + if corners.is_empty() { + return; + } + let sel = egui::Rect::from_points(&corners); + // A character scrolled off the canvas has no row. + if !sel.intersects(xf.rect) { + return; + } + let t = Tokens::get(ui.ctx()); + let count = found.count(); + let at = place(sel, bar_size(count, found.more), xf.rect.shrink(4.0), vertical, super::task_bar_rect(ui.ctx())); + let px = (CELL * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; + let (mut pick, mut more) = (None, false); + egui::Area::new(bar_id()).order(egui::Order::Middle).fixed_pos(at).show(ui.ctx(), |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(4)) + .inner_margin(egui::Margin::same(PAD as i8 - 1)) + .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: egui::Color32::from_black_alpha(70) }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + for i in 0..count { + let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 2, t.hover); + } + ui.painter().rect_stroke(rect.shrink(0.5), 0, Stroke::new(0.5, t.border), StrokeKind::Inside); + if let Some(tex) = found.texture(ui.ctx(), i, px) { + let uv = egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + ui.painter().image(tex.id(), rect, uv, t.text_strong); + } + let tip = found.tip(i); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); + if resp.on_hover_text(&tip).clicked() { + pick = Some(i); + } + } + if found.more { + let (rect, resp) = ui.allocate_exact_size(vec2(MORE, CELL), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 2, t.hover); + } + let c = rect.center(); + let arrow = vec![pos2(c.x - 2.5, c.y - 5.0), pos2(c.x + 3.5, c.y), pos2(c.x - 2.5, c.y + 5.0)]; + ui.painter().add(Shape::convex_polygon(arrow, t.text_strong, Stroke::NONE)); + let tip = tl!("Show all alternates in the Glyphs panel"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + more = resp.on_hover_text(tip).clicked(); + } + }); + }); + }); + if let Some(i) = pick { + found.pick(app, i); + } else if more { + glyphs::show_alternates(app, ui.ctx()); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/canvas/alternates_bar/tests.rs b/crates/ui-egui/src/canvas/alternates_bar/tests.rs new file mode 100644 index 0000000000..be2eb59f97 --- /dev/null +++ b/crates/ui-egui/src/canvas/alternates_bar/tests.rs @@ -0,0 +1,247 @@ +use egui::{Pos2, Rect}; +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_text::FontDb; + +use super::*; + +/// The Type tool editing `text` in a new document. +fn editing(text: &str, vertical: bool) -> (VectorcraftApp, Value) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 120, "y": 80, "text": text, "vertical": vertical})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + (app, id) +} + +fn select(app: &mut VectorcraftApp, start: usize, end: usize) { + app.session.set_tool_option("select", &json!({"start": start, "end": end})); +} + +const SCREEN: Rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 600.0)); + +/// One frame of the canvas with `events`; where the row of alternates is afterwards, if it shows. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { + egui_extras::install_image_loaders(ctx); + let raw = egui::RawInput { screen_rect: Some(SCREEN), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| super::super::show(app, ui)); + out.textures_delta.clear(); + bar_rect(ctx) +} + +/// The glyphs the row offers and whether it has the arrow, as the canvas finds them. +fn offered(app: &mut VectorcraftApp, ctx: &egui::Context) -> Option<(Vec, bool)> { + let mut found = None; + let raw = egui::RawInput { screen_rect: Some(SCREEN), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + found = glyphs::on_canvas(app, ui).map(|f| ((0..f.count()).filter_map(|i| f.gid(i)).collect(), f.more)); + }); + out.textures_delta.clear(); + found +} + +/// A click at `at`, with a frame before it that lays the row out and moves the pointer there. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, ctx, vec![button(true), button(false)]); +} + +/// The center of cell `i` of the row at `bar`. +fn cell(bar: Rect, i: usize) -> Pos2 { + bar.min + vec2(PAD + CELL * (i as f32 + 0.5), PAD + CELL / 2.0) +} + +/// The center of the arrow of a row of `count` cells at `bar`. +fn arrow(bar: Rect, count: usize) -> Pos2 { + bar.min + vec2(PAD + CELL * count as f32 + MORE / 2.0, PAD + CELL / 2.0) +} + +fn glyph_ids(app: &VectorcraftApp, id: &Value) -> Vec { + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + let mut glyphs = vectorcraft_text::layout(FontDb::global(), text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs.iter().map(|g| g.gid).collect() +} + +fn undo_steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// The screen box of bytes `a..b` of the text, as the canvas of [`frame`] draws it. +fn character_box(app: &VectorcraftApp, id: &Value, a: usize, b: usize) -> Rect { + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + let lay = vectorcraft_text::layout(FontDb::global(), text); + let xf = app.canvas_xf().expect("a frame was drawn"); + let corners: Vec = vectorcraft_text::selection_quads(&lay, a, b).iter().flatten().map(|p| xf.to_screen(text.xf * *p)).collect(); + Rect::from_points(&corners) +} + +/// With the Type tool and exactly one character selected, the canvas shows up to five of that +/// character's alternates beside it, without the glyph it is drawn with, and an arrow when it has +/// more. A click draws the character with the glyph as one undo step and keeps the text. A caret, +/// a longer selection, a character without alternates, another tool and the preference turned off +/// each show no row. +#[test] +fn one_selected_character_shows_its_alternates_and_a_click_replaces_its_glyph() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let (mut app, id) = editing("xay", false); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + let ctx = egui::Context::default(); + let drawn = glyph_ids(&app, &id); + + select(&mut app, 1, 1); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "a caret selects no character"); + select(&mut app, 0, 2); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "two characters are selected"); + select(&mut app, 0, 1); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "x has no alternates"); + + select(&mut app, 1, 2); + let bar = frame(&mut app, &ctx, vec![]).expect("the a has alternates"); + let task_bar = super::super::task_bar_rect(&ctx).expect("the task bar shows for type being edited"); + let (gids, more) = offered(&mut app, &ctx).expect("offered"); + assert_eq!(gids.len(), glyphs::ON_CANVAS_ALTERNATES, "five of the six alternates"); + assert!(more, "the sixth is in the Glyphs panel"); + assert_eq!(gids[0], u1); + assert!(!gids.contains(&drawn[1]), "the glyph the a is drawn with is not offered"); + assert_eq!(bar.size(), bar_size(5, true), "five cells and the arrow"); + // The Contextual Task Bar is under the type, so the row goes over the character. + // The row is placed on whole pixels, so it is within one of where the character's box puts it. + let sel = character_box(&app, &id, 1, 2); + assert!(task_bar.intersects(Rect::from_min_size(pos2(bar.left(), sel.bottom() + GAP), bar.size())), "{task_bar:?} {sel:?}"); + assert!((sel.top() - bar.bottom() - GAP).abs() < 1.0, "over the character: {bar:?} {sel:?}"); + assert!((bar.center().x - sel.center().x).abs() < 1.0, "centered on it"); + // Without the task bar the row is under the character. + app.ui.task_bar = false; + frame(&mut app, &ctx, vec![]); + let under = frame(&mut app, &ctx, vec![]).expect("the row"); + assert!((under.top() - sel.bottom() - GAP).abs() < 1.0, "under the character: {under:?} {sel:?}"); + app.ui.task_bar = true; + frame(&mut app, &ctx, vec![]); + assert_eq!(frame(&mut app, &ctx, vec![]), Some(bar)); + + // A click on the first glyph. + let undo = undo_steps(&app); + click(&mut app, &ctx, cell(bar, 0)); + assert_eq!(undo_steps(&app), undo + 1, "one undo step; status: {}", app.ui.status); + assert_eq!(glyph_ids(&app, &id), [drawn[0], u1, drawn[2]], "the a is drawn with the glyph"); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "xay", "the text keeps its characters"); + + // The row stays for the same character: its default glyph first, the drawn glyph left out. + let bar = frame(&mut app, &ctx, vec![]).expect("still one character selected"); + let (gids, _) = offered(&mut app, &ctx).expect("offered"); + assert_eq!(gids[0], drawn[1], "the default glyph comes first"); + assert!(!gids.contains(&u1)); + click(&mut app, &ctx, cell(bar, 0)); + assert_eq!(undo_steps(&app), undo + 2); + assert_eq!(glyph_ids(&app, &id), drawn, "the default glyph again"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [drawn[0], u1, drawn[2]], "undo takes one pick back"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), drawn); + + // Preferences › Type › Show Character Alternates. + select(&mut app, 1, 2); + assert!(frame(&mut app, &ctx, vec![]).is_some()); + app.run("prefs.set", json!({"key": "showCharacterAlternates", "value": false})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "the preference is off"); + app.run("prefs.set", json!({"key": "showCharacterAlternates", "value": true})).unwrap(); + assert!(frame(&mut app, &ctx, vec![]).is_some()); + + app.session.select_tool("select", Default::default()).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "the Selection tool edits no text"); +} + +/// The arrow at the end of the row opens the Glyphs panel on Show › Alternates for Current +/// Selection and changes nothing in the document. +#[test] +fn the_arrow_opens_the_glyphs_panel_on_the_alternates() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let (mut app, id) = editing("xay", false); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 1, 2); + let ctx = egui::Context::default(); + app.run("window.panel.close", json!({"panel": "glyphs"})).ok(); + assert!(!crate::panel_docking::visible(&app.ui, "glyphs")); + let bar = frame(&mut app, &ctx, vec![]).expect("the row"); + let (undo, drawn) = (undo_steps(&app), glyph_ids(&app, &id)); + click(&mut app, &ctx, arrow(bar, 5)); + assert!(crate::panel_docking::visible(&app.ui, "glyphs"), "status: {}", app.ui.status); + // The Show menu's entry after its seven character subsets. + assert_eq!(crate::panels::pstate::(&ctx, "gl-subset"), 7); + assert_eq!(undo_steps(&app), undo); + assert_eq!(glyph_ids(&app, &id), drawn); +} + +/// A medial letter of a joined Arabic word gets the alternates of its medial form, and a click +/// applies the feature that draws one to that letter alone: its neighbors keep their joining +/// forms. Three alternates need no arrow. +#[test] +fn a_letter_of_a_joined_word_keeps_the_word_joined() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let g = |c| u32::from(source_sans_gid(c).expect("a glyph")); + let (mut app, id) = editing("\u{628}\u{62A}\u{646}", false); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + select(&mut app, 2, 4); + let ctx = egui::Context::default(); + let bar = frame(&mut app, &ctx, vec![]).expect("the medial teh has alternates"); + let (gids, more) = offered(&mut app, &ctx).expect("offered"); + assert_eq!((gids.len(), more), (3, false), "the medial form's three alternates: {gids:?}"); + assert_eq!(bar.size(), bar_size(3, false), "three cells and no arrow"); + let at = gids.iter().position(|gid| *gid == g('x')).expect("the alternate salt=2 draws"); + let undo = undo_steps(&app); + click(&mut app, &ctx, cell(bar, at)); + assert_eq!(undo_steps(&app), undo + 1, "status: {}", app.ui.status); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')], "the neighbors keep their initial and final forms"); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "\u{628}\u{62A}\u{646}"); + assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "a feature draws it, not a glyph choice"); +} + +/// The row stands on the right of a character of vertical type, level with it. +#[test] +fn the_row_stands_beside_a_character_of_vertical_type() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let (mut app, id) = editing("xay", true); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 1, 2); + let ctx = egui::Context::default(); + app.ui.task_bar = false; + // The first frame fits the view to the canvas. + frame(&mut app, &ctx, vec![]); + let bar = frame(&mut app, &ctx, vec![]).expect("the row"); + let sel = character_box(&app, &id, 1, 2); + assert!((bar.left() - sel.right() - GAP).abs() < 1.0, "on the right of the character: {bar:?} {sel:?}"); + assert!((bar.center().y - sel.center().y).abs() < 1.0, "level with it"); +} + +/// [`place`]: under the character, over it when the canvas ends below or the task bar is there, +/// on the right or left of vertical type, and inside the canvas when neither side has room. +#[test] +fn the_row_is_placed_on_the_side_that_has_room() { + let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0)); + let size = vec2(100.0, 34.0); + let sel = Rect::from_min_max(pos2(190.0, 100.0), pos2(210.0, 130.0)); + assert_eq!(place(sel, size, area, false, None), pos2(150.0, 136.0), "under"); + let low = sel.translate(vec2(0.0, 150.0)); + assert_eq!(place(low, size, area, false, None), pos2(150.0, 210.0), "over: no room under"); + let task_bar = Rect::from_min_max(pos2(100.0, 140.0), pos2(300.0, 180.0)); + assert_eq!(place(sel, size, area, false, Some(task_bar)), pos2(150.0, 60.0), "over: the task bar is under"); + let edge = sel.translate(vec2(-185.0, 0.0)); + assert_eq!(place(edge, size, area, false, None), pos2(0.0, 136.0), "moved along the line to stay on the canvas"); + assert_eq!(place(sel, size, area, true, None), pos2(216.0, 98.0), "vertical type: on the right"); + let right = sel.translate(vec2(150.0, 0.0)); + assert_eq!(place(right, size, area, true, None), pos2(234.0, 98.0), "vertical type: on the left"); + let small = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 60.0)); + let inside = Rect::from_min_max(pos2(190.0, 20.0), pos2(210.0, 40.0)); + assert_eq!(place(inside, size, small, false, None), pos2(150.0, 26.0), "no room on either side: kept inside the canvas"); +} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index aefd9253e9..119c811d0b 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -276,26 +276,27 @@ pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: /// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's /// attachment point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { - use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; + use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, Widgets}; if !matches!(tool, "selection" | "objectsOnPath") { return; } - member_widgets(p, xf, doc, selection); - if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) { + let widgets = Widgets::of(doc, selection, xf.zoom); + member_widgets(p, xf, doc, &widgets.members); + if let Some(w) = widgets.rotate_all { let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); p.line_segment([on, c], Stroke::new(1.0, color)); p.circle_filled(c, ROTATE_ALL_RADIUS_PX, Color32::WHITE); p.circle_stroke(c, ROTATE_ALL_RADIUS_PX, Stroke::new(1.0, color)); } - space_widget(p, xf, doc, selection); - move_all_widget(p, xf, doc, selection); + space_widget(p, xf, doc, widgets.space); + move_all_widget(p, xf, doc, widgets.move_all); } /// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen /// object. They are drawn under the other widgets, which a press takes first. -fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { - use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget}; - for w in MemberWidget::all(doc, selection, xf.zoom) { +fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, widgets: &[vectorcraft_tools::objects_on_path::MemberWidget]) { + use vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX; + for w in widgets { let c = xf.to_screen(w.at); if w.chosen { p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE)); @@ -310,9 +311,9 @@ pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb); /// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square /// joined by a line to the last object's attachment point. It stays while dragged. -fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { - use vectorcraft_tools::objects_on_path::{SPACE_HALF_PX, SpaceWidget}; - let Some(w) = SpaceWidget::of(doc, selection, xf.zoom) else { return }; +fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, w: Option) { + use vectorcraft_tools::objects_on_path::SPACE_HALF_PX; + let Some(w) = w else { return }; let (on, c, color) = (xf.to_screen(w.last), xf.to_screen(w.at), c32(doc.layer_color(w.group))); let r = egui::Rect::from_center_size(c, vec2(2.0 * SPACE_HALF_PX, 2.0 * SPACE_HALF_PX)); p.line_segment([on, c], Stroke::new(1.0, color)); @@ -323,9 +324,9 @@ fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, sel /// The Move All widget (beside the Rotate All widget, when the objects have room to move along the /// path): a diamond below the first object joined by a line to its attachment point. It stays while /// dragged. -fn move_all_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { - use vectorcraft_tools::objects_on_path::{MOVE_ALL_HALF_PX, MoveAllWidget}; - let Some(w) = MoveAllWidget::of(doc, selection, xf.zoom) else { return }; +fn move_all_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, w: Option) { + use vectorcraft_tools::objects_on_path::MOVE_ALL_HALF_PX; + let Some(w) = w else { return }; let (on, c, color) = (xf.to_screen(w.first), xf.to_screen(w.at), c32(doc.layer_color(w.group))); let h = MOVE_ALL_HALF_PX; p.line_segment([on, c], Stroke::new(1.0, color)); diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index add0fbed73..cd18d6b153 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -140,7 +140,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } // Nothing to export keeps the dialog open too (before the save dialog, not after it). An // exporter plug-in decides for itself what it can write. - if !use_artboards && plugin.is_none() && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) { + if !use_artboards + && plugin.is_none() + && app.session.active().is_some_and(|s| vectorcraft_engine::cmd::fileio::export_art_bounds(&s.doc, f.id).is_none()) + { return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into()); } app.ui.dialog = None; diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index ca37940601..f8a2e05515 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -304,6 +304,23 @@ fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() { assert!(written.borrow().is_empty()); } +/// Art that a plot leaves out (here at 0% opacity) is nothing to export as HP-GL of the art's +/// bounds, before the save dialog; other formats still export it. +#[test] +fn art_a_plot_leaves_out_keeps_hpgl_export_as_open_before_the_save_dialog() { + let (mut app, _) = app(1); + app.services.pick_save = Some(Box::new(|_: &crate::FilePick| panic!("no save dialog without art the plot draws"))); + app.run("select.all", json!({})).unwrap(); + app.run("transparency.set", json!({"opacity": 0})).unwrap(); + app.run("file.exportAs", json!({"format": "hpgl"})).unwrap(); + assert!(confirm(&mut app).unwrap_err().contains("no visible art")); + assert_eq!(kind(&app), Some("exportAs")); + app.services.pick_save = Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))); + set(&mut app, "format", json!("png")); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("pngOptions")); +} + #[test] fn pdf_artboards_continue_in_the_save_pdf_dialog() { let (mut app, written) = app(3); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28944173dd..1e801cff32 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -889,6 +889,9 @@ font-technology Variable Променливи The selected character's font is not available. Шрифтът на избрания знак не е наличен. No alternates for the selected character. Няма алтернативи за избрания знак. Double-click to replace Двойно щракване за замяна + Click to replace Щракване за замяна + Show all alternates in the Glyphs panel Показване на всички алтернативи в панела „Глифи“ + Show Character Alternates Показване на алтернативите на знака Select one character with the Type tool first. Първо изберете един знак с инструмента „Текст“. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} е глиф на {font}, а не на шрифта, с който е зададен избраният текст ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} принадлежи на {base} (U+{base_code}) {place}; избраният от вас знак е {sel} (U+{code}). @@ -6860,6 +6863,9 @@ breakText Glyphs Глифи Open Tutorial Sample Отваряне на примерния файл на урока Finish Tutorial Завършване на урок Next Tutorial Следващ урок + Show Learn Tab Показване на раздел в Обучение + Show Skill Показване на умение + Back One Level Едно ниво назад Back to Skills Обратно към уменията @msg tutorial `{id}` has no practice sample урокът `{id}` няма примерен файл за упражнение @msg open the practice sample of `{id}` first (see learn.openSample) първо отворете примерния файл за упражнение на `{id}` (вижте learn.openSample) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..ec09c33484 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1701,6 +1701,9 @@ digits Malayalam Malajalam The selected character's font is not available. Font odabranog znaka nije dostupan. No alternates for the selected character. Nema alternativa za odabrani znak. Double-click to replace Dvostruki klik za zamjenu + Click to replace Klik za zamjenu + Show all alternates in the Glyphs panel Prikaži sve alternative u panelu Glifovi + Show Character Alternates Prikaži alternative znaka Select one character with the Type tool first. Prvo odaberite jedan znak alatom za tekst. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glif fonta {font}, a ne fonta kojim je postavljen odabrani tekst ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pripada znaku {base} (U+{base_code}) {place}; vaš odabir je {sel} (U+{code}). @@ -6170,7 +6173,7 @@ axis Y Axis Y osa @plural Result: {n}|Results: {n} Rezultat: {n}|Rezultati: {n}|Rezultati: {n} @msg select one object odaberite jedan objekat @msg Global Edit works on paths, compound paths, symbol instances and groups of them Globalno uređivanje radi na putanjama, složenim putanjama, instancama simbola i njihovim grupama -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nisu pronađeni slični objekti (rotirane ili zrcaljene kopije se ne podudaraju); promijenite opcije globalnog uređivanja i pokušajte ponovo +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nisu pronađeni slični objekti (zrcaljene kopije se ne podudaraju); promijenite opcije globalnog uređivanja i pokušajte ponovo @msg Global Edit is off Globalno uređivanje je isključeno @msg only an object of the Global Edit other than the one it started from can be left out iz globalnog uređivanja može se isključiti samo objekt koji nije onaj od kojeg je počelo @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ili raspon poput 1, 3-5, a ne {v} @@ -6913,6 +6916,9 @@ shape Open Otvoren Open Tutorial Sample Otvori uzorak tutorijala Finish Tutorial Završi tutorijal Next Tutorial Sljedeći tutorijal + Show Learn Tab Prikaži karticu u Učenju + Show Skill Prikaži vještinu + Back One Level Jedan nivo nazad Back to Skills Nazad na vještine @msg tutorial `{id}` has no practice sample tutorijal `{id}` nema uzorak za vježbu @msg open the practice sample of `{id}` first (see learn.openSample) prvo otvorite uzorak za vježbu tutorijala `{id}` (vidi learn.openSample) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..583ea386c8 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1307,6 +1307,9 @@ antiAlias Sharp Nítid The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible. No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat. Double-click to replace Feu doble clic per substituir + Click to replace Feu clic per substituir + Show all alternates in the Glyphs panel Mostra totes les alternatives al tauler Glifs + Show Character Alternates Mostra les alternatives del caràcter Select one character with the Type tool first. Selecciona primer un caràcter amb l’eina de text. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} és un glif de {font}, no del tipus de lletra amb què està escrit el text seleccionat ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertany a {base} (U+{base_code}) {place}; la teva selecció és {sel} (U+{code}). @@ -5866,7 +5869,7 @@ digits Malayalam Malaiàlam @msg no segment {segment} on line {line} no hi ha el segment {segment} a la línia {line} @msg no selected slice in the image: select slices, or save all of them no hi ha cap tall seleccionat a la imatge: seleccioneu talls o deseu-los tots @msg no selection bounds no hi ha límits de selecció -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again no s’ha trobat cap objecte similar (les còpies girades o reflexades no coincideixen); canvieu les opcions d’edició global i torneu-ho a provar +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again no s’ha trobat cap objecte similar (les còpies reflexades no coincideixen); canvieu les opcions d’edició global i torneu-ho a provar @msg Global Edit is off L’edició global no està activa @msg only an object of the Global Edit other than the one it started from can be left out només es pot excloure un objecte de l’edició global que no sigui aquell amb què va començar @msg no slice selected no hi ha cap tall seleccionat @@ -7448,6 +7451,9 @@ breakText Glyphs Glifs Open Tutorial Sample Obre la mostra del tutorial Finish Tutorial Acaba el tutorial Next Tutorial Tutorial següent + Show Learn Tab Mostra una pestanya d'Aprèn + Show Skill Mostra l'habilitat + Back One Level Torna un nivell enrere Back to Skills Torna a les habilitats @msg tutorial `{id}` has no practice sample el tutorial `{id}` no té cap mostra de pràctica @msg open the practice sample of `{id}` first (see learn.openSample) obriu primer la mostra de pràctica de `{id}` (vegeu learn.openSample) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..54082ffcfd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1730,6 +1730,9 @@ digits Malayalam Malajálamské The selected character's font is not available. Písmo vybraného znaku není k dispozici. No alternates for the selected character. Vybraný znak nemá žádné alternativy. Double-click to replace Dvojklikem nahradíte + Click to replace Kliknutím nahradíte + Show all alternates in the Glyphs panel Zobrazit všechny alternativy v panelu Glyfy + Show Character Alternates Zobrazovat alternativy znaku Select one character with the Type tool first. Nejprve nástrojem Text vyberte jeden znak. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glyf písma {font}, ne písma, kterým je vybraný text nastaven ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} patří znaku {base} (U+{base_code}) {place}; váš výběr je {sel} (U+{code}). @@ -6674,7 +6677,7 @@ axis Y Axis osa Y @plural Result: {n}|Results: {n} Výsledek: {n}|Výsledky: {n}|Výsledky: {n} @msg select one object vyberte jeden objekt @msg Global Edit works on paths, compound paths, symbol instances and groups of them Globální úpravy pracují s cestami, složenými cestami, instancemi symbolů a jejich skupinami -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nebyly nalezeny žádné podobné objekty (otočené a zrcadlené kopie se nevyhledávají); změňte možnosti globálních úprav a zkuste to znovu +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nebyly nalezeny žádné podobné objekty (zrcadlené kopie se nevyhledávají); změňte možnosti globálních úprav a zkuste to znovu @msg Global Edit is off Globální úpravy nejsou zapnuté @msg only an object of the Global Edit other than the one it started from can be left out z globálních úprav lze vyřadit jen jiný objekt než ten, od kterého začaly @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all nebo rozsah, například 1, 3-5, ne {v} @@ -7583,6 +7586,9 @@ breakText Glyphs Glyfy Open Tutorial Sample Otevřít ukázku výukového kurzu Finish Tutorial Dokončit výukový kurz Next Tutorial Další výukový kurz + Show Learn Tab Zobrazit kartu Výuky + Show Skill Zobrazit dovednost + Back One Level O úroveň zpět Back to Skills Zpět na dovednosti @msg tutorial `{id}` has no practice sample výukový kurz `{id}` nemá cvičnou ukázku @msg open the practice sample of `{id}` first (see learn.openSample) nejprve otevřete cvičnou ukázku kurzu `{id}` (viz learn.openSample) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b3de0ae89a..dd5aba4de6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1636,6 +1636,9 @@ digits Malayalam Malayalam The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig. No alternates for the selected character. Ingen alternativer til det markerede tegn. Double-click to replace Dobbeltklik for at erstatte + Click to replace Klik for at erstatte + Show all alternates in the Glyphs panel Vis alle alternativer i Glyffer-panelet + Show Character Alternates Vis tegnalternativer Select one character with the Type tool first. Markér først ét tegn med tekstværktøjet. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} er et glyf i {font}, ikke i den skrifttype, den markerede tekst er sat i ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hører til {base} (U+{base_code}) {place}; din markering er {sel} (U+{code}). @@ -6875,6 +6878,9 @@ breakText Glyphs Glyffer Open Tutorial Sample Åbn eksempel til vejledning Finish Tutorial Afslut vejledning Next Tutorial Næste vejledning + Show Learn Tab Vis fane i Lær + Show Skill Vis færdighed + Back One Level Ét niveau tilbage Back to Skills Tilbage til færdigheder @msg tutorial `{id}` has no practice sample vejledningen `{id}` har intet øvelseseksempel @msg open the practice sample of `{id}` first (see learn.openSample) åbn først øvelseseksemplet til `{id}` (se learn.openSample) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..698d16a546 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -891,6 +891,9 @@ font-technology Variable Variabel The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar. No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen. Double-click to replace Doppelklicken zum Ersetzen + Click to replace Klicken zum Ersetzen + Show all alternates in the Glyphs panel Alle Alternativen im Bedienfeld „Glyphen“ anzeigen + Show Character Alternates Zeichenalternativen anzeigen Select one character with the Type tool first. Wählen Sie zuerst mit dem Text-Werkzeug ein Zeichen aus. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} ist eine Glyphe von {font}, nicht der Schrift, in der der ausgewählte Text gesetzt ist ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} gehört zu {base} (U+{base_code}) {place}; Ihre Auswahl ist {sel} (U+{code}). @@ -6266,7 +6269,7 @@ axis Y Axis Y-Achse @plural Result: {n}|Results: {n} Ergebnis: {n}|Ergebnisse: {n} @msg select one object wählen Sie ein Objekt aus @msg Global Edit works on paths, compound paths, symbol instances and groups of them Globale Bearbeitung funktioniert mit Pfaden, zusammengesetzten Pfaden, Symbolinstanzen und Gruppen daraus -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again keine ähnlichen Objekte gefunden (gedrehte oder gespiegelte Kopien werden nicht erkannt); ändern Sie die Optionen für globale Bearbeitung und versuchen Sie es erneut +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again keine ähnlichen Objekte gefunden (gespiegelte Kopien werden nicht erkannt); ändern Sie die Optionen für globale Bearbeitung und versuchen Sie es erneut @msg Global Edit is off Globale Bearbeitung ist nicht aktiv @msg only an object of the Global Edit other than the one it started from can be left out nur ein Objekt der globalen Bearbeitung außer dem, mit dem sie begonnen hat, kann ausgeschlossen werden @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all oder ein Bereich wie 1, 3-5, nicht {v} @@ -7063,6 +7066,9 @@ breakText Glyphs Glyphen Open Tutorial Sample Übungsdatei des Lernprogramms öffnen Finish Tutorial Lernprogramm abschließen Next Tutorial Nächstes Lernprogramm + Show Learn Tab Registerkarte in Lernen anzeigen + Show Skill Fertigkeit anzeigen + Back One Level Eine Ebene zurück Back to Skills Zurück zu den Fertigkeiten @msg tutorial `{id}` has no practice sample das Lernprogramm `{id}` hat keine Übungsdatei @msg open the practice sample of `{id}` first (see learn.openSample) öffnen Sie zuerst die Übungsdatei von `{id}` (siehe learn.openSample) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..a838aaac80 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -862,6 +862,9 @@ font-technology Variable Μεταβλητές The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη. No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα. Double-click to replace Διπλό κλικ για αντικατάσταση + Click to replace Κλικ για αντικατάσταση + Show all alternates in the Glyphs panel Εμφάνιση όλων των εναλλακτικών στον πίνακα Γλύφοι + Show Character Alternates Εμφάνιση εναλλακτικών χαρακτήρα Select one character with the Type tool first. Επιλέξτε πρώτα έναν χαρακτήρα με το εργαλείο κειμένου. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). Ο γλύφος {glyph} ανήκει στη γραμματοσειρά {font}, όχι σε αυτήν του επιλεγμένου κειμένου ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). Ο γλύφος {glyph} ανήκει στο {base} (U+{base_code}) {place}· η επιλογή σας είναι {sel} (U+{code}). @@ -6626,7 +6629,7 @@ axis Y Axis Άξονας Y @plural Result: {n}|Results: {n} Αποτέλεσμα: {n}|Αποτελέσματα: {n} @msg select one object επιλέξτε ένα αντικείμενο @msg Global Edit works on paths, compound paths, symbol instances and groups of them Η καθολική επεξεργασία λειτουργεί σε διαδρομές, σύνθετες διαδρομές, παρουσίες συμβόλων και ομάδες τους -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again δεν βρέθηκαν παρόμοια αντικείμενα (τα περιστραμμένα ή κατοπτρισμένα αντίγραφα δεν εντοπίζονται)· αλλάξτε τις επιλογές καθολικής επεξεργασίας και δοκιμάστε ξανά +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again δεν βρέθηκαν παρόμοια αντικείμενα (τα κατοπτρισμένα αντίγραφα δεν εντοπίζονται)· αλλάξτε τις επιλογές καθολικής επεξεργασίας και δοκιμάστε ξανά @msg Global Edit is off Η καθολική επεξεργασία δεν είναι ενεργή @msg only an object of the Global Edit other than the one it started from can be left out μπορεί να εξαιρεθεί μόνο ένα αντικείμενο της καθολικής επεξεργασίας εκτός από εκείνο με το οποίο ξεκίνησε @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ή ένα εύρος όπως 1, 3-5, όχι {v} @@ -7420,6 +7423,9 @@ breakText Glyphs Γλύφοι Open Tutorial Sample Άνοιγμα δείγματος οδηγού εκμάθησης Finish Tutorial Ολοκλήρωση οδηγού εκμάθησης Next Tutorial Επόμενος οδηγός εκμάθησης + Show Learn Tab Εμφάνιση καρτέλας της Εκμάθησης + Show Skill Εμφάνιση δεξιότητας + Back One Level Ένα επίπεδο πίσω Back to Skills Πίσω στις δεξιότητες @msg tutorial `{id}` has no practice sample ο οδηγός εκμάθησης `{id}` δεν έχει δείγμα εξάσκησης @msg open the practice sample of `{id}` first (see learn.openSample) ανοίξτε πρώτα το δείγμα εξάσκησης του `{id}` (δείτε learn.openSample) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..719b9dc668 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -904,6 +904,9 @@ font-technology Variable Variables The selected character's font is not available. La fuente del carácter seleccionado no está disponible. No alternates for the selected character. No hay alternativas para el carácter seleccionado. Double-click to replace Doble clic para reemplazar + Click to replace Clic para reemplazar + Show all alternates in the Glyphs panel Mostrar todas las alternativas en el panel Glifos + Show Character Alternates Mostrar alternativas de carácter Select one character with the Type tool first. Primero selecciona un carácter con la herramienta Texto. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} es un glifo de {font}, no de la fuente del texto seleccionado ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertenece a {base} (U+{base_code}) {place}; tu selección es {sel} (U+{code}). @@ -6617,7 +6620,7 @@ axis Y Axis Eje Y @plural Result: {n}|Results: {n} Resultado: {n}|Resultados: {n} @msg select one object seleccione un objeto @msg Global Edit works on paths, compound paths, symbol instances and groups of them La edición global funciona con trazados, trazados compuestos, instancias de símbolos y grupos de ellos -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again no se encontraron objetos similares (las copias giradas o reflejadas no coinciden); cambie las opciones de edición global e inténtelo de nuevo +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again no se encontraron objetos similares (las copias reflejadas no coinciden); cambie las opciones de edición global e inténtelo de nuevo @msg Global Edit is off La edición global no está activa @msg only an object of the Global Edit other than the one it started from can be left out solo se puede excluir un objeto de la edición global distinto de aquel con el que empezó @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervalo como 1, 3-5, no {v} @@ -7527,6 +7530,9 @@ breakText Glyphs Glifos Open Tutorial Sample Abrir muestra del tutorial Finish Tutorial Terminar tutorial Next Tutorial Tutorial siguiente + Show Learn Tab Mostrar pestaña de Aprender + Show Skill Mostrar habilidad + Back One Level Volver un nivel Back to Skills Volver a las habilidades @msg tutorial `{id}` has no practice sample el tutorial `{id}` no tiene muestra de práctica @msg open the practice sample of `{id}` first (see learn.openSample) abre primero la muestra de práctica de `{id}` (consulta learn.openSample) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..5c04678407 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1703,6 +1703,9 @@ digits Malayalam Malajalam The selected character's font is not available. Valitun merkin fontti ei ole saatavilla. No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja. Double-click to replace Korvaa kaksoisnapsauttamalla + Click to replace Korvaa napsauttamalla + Show all alternates in the Glyphs panel Näytä kaikki vaihtoehdot Merkit-paneelissa + Show Character Alternates Näytä merkin vaihtoehdot Select one character with the Type tool first. Valitse ensin yksi merkki tekstityökalulla. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} on fontin {font} glyfi, ei valitun tekstin fontin ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} kuuluu merkille {base} (U+{base_code}) {place}; valintasi on {sel} (U+{code}). @@ -6704,7 +6707,7 @@ axis Y Axis Y-akseli @plural Result: {n}|Results: {n} Tulos: {n}|Tulokset: {n} @msg select one object valitse yksi objekti @msg Global Edit works on paths, compound paths, symbol instances and groups of them Yleismuokkaus toimii poluille, yhdistelmäpoluille, symbolien esiintymille ja niiden ryhmille -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again samankaltaisia objekteja ei löytynyt (kierrettyjä tai peilattuja kopioita ei tunnisteta); muuta yleismuokkauksen asetuksia ja yritä uudelleen +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again samankaltaisia objekteja ei löytynyt (peilattuja kopioita ei tunnisteta); muuta yleismuokkauksen asetuksia ja yritä uudelleen @msg Global Edit is off Yleismuokkaus ei ole käynnissä @msg only an object of the Global Edit other than the one it started from can be left out yleismuokkauksesta voi jättää pois vain muun objektin kuin sen, josta se alkoi @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all tai alue, kuten 1, 3-5, ei {v} @@ -7502,6 +7505,9 @@ breakText Glyphs Merkit Open Tutorial Sample Avaa opetusohjelman harjoitustiedosto Finish Tutorial Viimeistele opetusohjelma Next Tutorial Seuraava opetusohjelma + Show Learn Tab Näytä Opi-paneelin välilehti + Show Skill Näytä taito + Back One Level Taso takaisin Back to Skills Takaisin taitoihin @msg tutorial `{id}` has no practice sample opetusohjelmalla `{id}` ei ole harjoitustiedostoa @msg open the practice sample of `{id}` first (see learn.openSample) avaa ensin opetusohjelman `{id}` harjoitustiedosto (ks. learn.openSample) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..d25cc7069c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -907,6 +907,9 @@ font-technology Variable Variables The selected character's font is not available. La police du caractère sélectionné n’est pas disponible. No alternates for the selected character. Aucune variante pour le caractère sélectionné. Double-click to replace Double-cliquez pour remplacer + Click to replace Cliquez pour remplacer + Show all alternates in the Glyphs panel Afficher toutes les variantes dans le panneau Glyphes + Show Character Alternates Afficher les variantes de caractère Select one character with the Type tool first. Sélectionnez d’abord un caractère avec l’outil Texte. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} est un glyphe de {font}, et non de la police du texte sélectionné ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartient à {base} (U+{base_code}) {place} ; votre sélection est {sel} (U+{code}). @@ -6669,7 +6672,7 @@ axis Y Axis Axe Y @plural Result: {n}|Results: {n} Résultat : {n}|Résultats : {n} @msg select one object sélectionnez un seul objet @msg Global Edit works on paths, compound paths, symbol instances and groups of them La modification globale s’applique aux tracés, aux tracés transparents, aux occurrences de symboles et aux groupes de ces objets -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again aucun objet similaire trouvé (les copies pivotées ou symétriques ne sont pas reconnues) ; modifiez les options de modification globale et réessayez +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again aucun objet similaire trouvé (les copies symétriques ne sont pas reconnues) ; modifiez les options de modification globale et réessayez @msg Global Edit is off La modification globale n’est pas active @msg only an object of the Global Edit other than the one it started from can be left out seul un objet de la modification globale autre que celui à partir duquel elle a commencé peut être exclu @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards` : all ou une plage telle que 1, 3-5, et non {v} @@ -7462,6 +7465,9 @@ breakText Glyphs Glyphes Open Tutorial Sample Ouvrir le fichier d’exercice du tutoriel Finish Tutorial Terminer le tutoriel Next Tutorial Tutoriel suivant + Show Learn Tab Afficher un onglet d'Apprendre + Show Skill Afficher la compétence + Back One Level Revenir d'un niveau Back to Skills Retour aux compétences @msg tutorial `{id}` has no practice sample le tutoriel `{id}` n’a pas de fichier d’exercice @msg open the practice sample of `{id}` first (see learn.openSample) ouvrez d’abord le fichier d’exercice de `{id}` (voir learn.openSample) diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..a36ac35c7a 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -1046,6 +1046,9 @@ Double-click to insert દાખલ કરવા ડબલ-ક્લિક કરો Double-click to rename નામ બદલવા ડબલ-ક્લિક કરો Double-click to replace બદલવા ડબલ-ક્લિક કરો + Click to replace બદલવા ક્લિક કરો + Show all alternates in the Glyphs panel ગ્લિફ્સ પેનલમાં બધા વૈકલ્પિક બતાવો + Show Character Alternates અક્ષરના વૈકલ્પિક બતાવો Double-click to target the contents સામગ્રી લક્ષ્ય બનાવવા ડબલ-ક્લિક કરો Download: ડાઉનલોડ: Drag ખેંચો @@ -4533,6 +4536,9 @@ antiAlias Sharp શાર્પ Open Tutorial Sample ટ્યુટોરિયલનો નમૂનો ખોલો Finish Tutorial ટ્યુટોરિયલ પૂર્ણ કરો Next Tutorial આગળનો ટ્યુટોરિયલ + Show Learn Tab શીખો ટૅબ બતાવો + Show Skill કૌશલ્ય બતાવો + Back One Level એક સ્તર પાછા જાઓ Back to Skills કૌશલ્યો પર પાછા જાઓ Cutting Tools કટિંગ ટૂલ્સ Stencil Bridges… સ્ટેન્સિલ બ્રિજિસ… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..f4a3a04807 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1463,6 +1463,9 @@ antiAlias Sharp शार्प Double-click to insert सम्मिलित करने के लिए डबल क्लिक करें Double-click to rename नाम बदलने के लिए डबल क्लिक करें Double-click to replace प्रतिस्थापित करने के लिए डबल क्लिक करें + Click to replace प्रतिस्थापित करने के लिए क्लिक करें + Show all alternates in the Glyphs panel ग्लिफ़्स पैनल में सभी ऑप्शन दिखाएँ + Show Character Alternates कैरेक्टर के ऑप्शन दिखाएँ Select one character with the Type tool first. पहले टाइप टूल से एक कैरेक्टर सिलेक्ट करें। {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} {font} का ग्लिफ़ है, उस फ़ॉन्ट का नहीं जिसमें सिलेक्ट किया टेक्स्ट सेट है ({text_font})। {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} {base} (U+{base_code}) का है, {place}; आपका सिलेक्शन {sel} (U+{code}) है। @@ -7413,7 +7416,7 @@ antiAlias Sharp शार्प @msg no selected slice in the image: select slices, or save all of them इमेज में कोई सिलेक्टेड स्लाइस नहीं: स्लाइस सिलेक्ट करें, या सभी स्लाइस सेव करें @msg no selection bounds कोई सिलेक्शन सीमा नहीं @msg no series {i} कोई श्रृंखला {i} -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again कोई समान ऑब्जेक्ट नहीं पाया (घूर्णित या परिलक्षित प्रतियां मिलान नहीं की जाती हैं); वैश्विक संपादन ऑप्शन्स बदलें और फिर से प्रयास करें +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again कोई समान ऑब्जेक्ट नहीं पाया (परिलक्षित प्रतियां मिलान नहीं की जाती हैं); वैश्विक संपादन ऑप्शन्स बदलें और फिर से प्रयास करें @msg no slice {n} in the image (see slice.list) इमेज में कोई स्लाइस {n} नहीं है (slice.list देखें) @msg no slice selected सिलेक्टेड कोई स्लाइस नहीं @msg no source art कोई स्रोत कला @@ -8858,6 +8861,9 @@ theme Appearance अपीयरेंस Open Tutorial Sample ट्यूटोरियल का नमूना खोलें Finish Tutorial ट्यूटोरियल पूरा करें Next Tutorial अगला ट्यूटोरियल + Show Learn Tab सीखें का टैब दिखाएँ + Show Skill कौशल दिखाएँ + Back One Level एक स्तर वापस Back to Skills कौशल पर वापस जाएँ @msg tutorial `{id}` has no practice sample ट्यूटोरियल `{id}` का कोई अभ्यास नमूना नहीं है @msg open the practice sample of `{id}` first (see learn.openSample) पहले `{id}` का अभ्यास नमूना खोलें (learn.openSample देखें) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a1f6100b2f..bea893c5ff 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1172,6 +1172,9 @@ font-technology Variable Változó The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el. No alternates for the selected character. A kijelölt karakternek nincsenek változatai. Double-click to replace Csere dupla kattintással + Click to replace Csere kattintással + Show all alternates in the Glyphs panel Minden változat megjelenítése a Glifák panelen + Show Character Alternates Karakterváltozatok megjelenítése Select one character with the Type tool first. Előbb jelölj ki egy karaktert a Szöveg eszközzel. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). A(z) {glyph} a(z) {font} glifája, nem a kijelölt szöveg betűtípusáé ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). A(z) {glyph} ehhez tartozik: {base} (U+{base_code}) {place}; a te kijelölésed: {sel} (U+{code}). @@ -6657,7 +6660,7 @@ axis Y Axis Y tengely @plural Result: {n}|Results: {n} Eredmény: {n} @msg select one object jelöljön ki egy objektumot @msg Global Edit works on paths, compound paths, symbol instances and groups of them A globális szerkesztés útvonalakon, összetett útvonalakon, szimbólumpéldányokon és ezek csoportjain működik -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nem található hasonló objektum (az elforgatott vagy tükrözött másolatok nem számítanak egyezésnek); módosítsa a globális szerkesztés beállításait, és próbálja újra +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nem található hasonló objektum (a tükrözött másolatok nem számítanak egyezésnek); módosítsa a globális szerkesztés beállításait, és próbálja újra @msg Global Edit is off A globális szerkesztés nincs bekapcsolva @msg only an object of the Global Edit other than the one it started from can be left out a globális szerkesztésből csak a kiindulási objektumtól eltérő objektum zárható ki @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all vagy egy tartomány, például 1, 3-5, nem {v} @@ -7451,6 +7454,9 @@ breakText Glyphs Glifák Open Tutorial Sample Oktatóanyag mintafájljának megnyitása Finish Tutorial Oktatóanyag befejezése Next Tutorial Következő oktatóanyag + Show Learn Tab Tanulás lap megjelenítése + Show Skill Készség megjelenítése + Back One Level Egy szinttel vissza Back to Skills Vissza a készségekhez @msg tutorial `{id}` has no practice sample nincs gyakorló mintafájl ehhez az oktatóanyaghoz: `{id}` @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) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c7c6b956db..92d97e00d9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1636,6 +1636,9 @@ digits Malayalam Malayalam The selected character's font is not available. Font karakter yang dipilih tidak tersedia. No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih. Double-click to replace Klik ganda untuk mengganti + Click to replace Klik untuk mengganti + Show all alternates in the Glyphs panel Tampilkan semua alternatif di panel Glif + Show Character Alternates Tampilkan alternatif karakter Select one character with the Type tool first. Pilih satu karakter dengan alat teks terlebih dahulu. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} adalah glif {font}, bukan dari font yang dipakai teks terpilih ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} milik {base} (U+{base_code}) {place}; pilihan Anda adalah {sel} (U+{code}). @@ -6875,6 +6878,9 @@ breakText Glyphs Glif Open Tutorial Sample Buka contoh tutorial Finish Tutorial Selesaikan tutorial Next Tutorial Tutorial berikutnya + Show Learn Tab Tampilkan tab Belajar + Show Skill Tampilkan keterampilan + Back One Level Kembali satu tingkat Back to Skills Kembali ke keterampilan @msg tutorial `{id}` has no practice sample tutorial `{id}` tidak memiliki contoh latihan @msg open the practice sample of `{id}` first (see learn.openSample) buka contoh latihan `{id}` terlebih dahulu (lihat learn.openSample) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6743704785..6fb07b1a01 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -912,6 +912,9 @@ font-technology Variable Variabili The selected character's font is not available. Il font del carattere selezionato non è disponibile. No alternates for the selected character. Nessuna alternativa per il carattere selezionato. Double-click to replace Fai doppio clic per sostituire + Click to replace Fai clic per sostituire + Show all alternates in the Glyphs panel Mostra tutte le alternative nel pannello Glifi + Show Character Alternates Mostra alternative carattere Select one character with the Type tool first. Seleziona prima un carattere con lo strumento Testo. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} è un glifo di {font}, non del font del testo selezionato ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartiene a {base} (U+{base_code}) {place}; la tua selezione è {sel} (U+{code}). @@ -6300,7 +6303,7 @@ axis Y Axis Asse Y @plural Result: {n}|Results: {n} Risultato: {n}|Risultati: {n} @msg select one object selezionate un oggetto @msg Global Edit works on paths, compound paths, symbol instances and groups of them La modifica globale funziona su tracciati, tracciati composti, istanze di simboli e gruppi di essi -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nessun oggetto simile trovato (le copie ruotate o riflesse non vengono riconosciute); cambiate le opzioni di modifica globale e riprovate +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nessun oggetto simile trovato (le copie riflesse non vengono riconosciute); cambiate le opzioni di modifica globale e riprovate @msg Global Edit is off La modifica globale non è attiva @msg only an object of the Global Edit other than the one it started from can be left out si può escludere solo un oggetto della modifica globale diverso da quello da cui è partita @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervallo come 1, 3-5, non {v} @@ -7097,6 +7100,9 @@ breakText Glyphs Glifi Open Tutorial Sample Apri l’esempio del tutorial Finish Tutorial Termina il tutorial Next Tutorial Tutorial successivo + Show Learn Tab Mostra una scheda di Impara + Show Skill Mostra competenza + Back One Level Indietro di un livello Back to Skills Torna alle competenze @msg tutorial `{id}` has no practice sample il tutorial `{id}` non ha un esempio di esercitazione @msg open the practice sample of `{id}` first (see learn.openSample) apri prima l’esempio di esercitazione di `{id}` (vedi learn.openSample) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9cdb8fcbe..6b59f5a370 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1557,6 +1557,9 @@ antiAlias Sharp シャープ The selected character's font is not available. 選択した文字のフォントを使用できません。 No alternates for the selected character. 選択した文字には異体字がありません。 Double-click to replace ダブルクリックで置換 + Click to replace クリックで置換 + Show all alternates in the Glyphs panel 字形パネルですべての異体字を表示 + Show Character Alternates 文字の異体字を表示 Select one character with the Type tool first. 先に文字ツールで文字を 1 つ選択してください。 {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} は {font} のグリフで、選択したテキストのフォント ({text_font}) のものではありません。 {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} は {base} (U+{base_code}) の{place}のグリフです。選択中の文字は {sel} (U+{code}) です。 @@ -8170,7 +8173,7 @@ axis Y Axis Y 軸 @plural Result: {n}|Results: {n} 結果: {n} @msg select one object オブジェクトを 1 つ選択してください @msg Global Edit works on paths, compound paths, symbol instances and groups of them グローバル編集はパス、複合パス、シンボルインスタンスとそれらのグループに使用できます -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 類似するオブジェクトが見つかりません(回転または反転したコピーは一致しません)。グローバル編集オプションを変更してもう一度試してください +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again 類似するオブジェクトが見つかりません(反転したコピーは一致しません)。グローバル編集オプションを変更してもう一度試してください @msg Global Edit is off グローバル編集はオフです @msg only an object of the Global Edit other than the one it started from can be left out 除外できるのは、グローバル編集を開始したオブジェクト以外のオブジェクトだけです @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all または 1, 3-5 のような範囲を指定してください ({v} ではなく) @@ -8967,6 +8970,9 @@ breakText Glyphs 字形 Open Tutorial Sample チュートリアルのサンプルを開く Finish Tutorial チュートリアルを完了 Next Tutorial 次のチュートリアル + Show Learn Tab 学ぶのタブを表示 + Show Skill スキルを表示 + Back One Level 1 つ上の階層に戻る Back to Skills スキルに戻る @msg tutorial `{id}` has no practice sample チュートリアル `{id}` には練習用サンプルがありません @msg open the practice sample of `{id}` first (see learn.openSample) 先に `{id}` の練習用サンプルを開いてください (learn.openSample を参照) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..7c872ed9b8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1574,6 +1574,9 @@ digits Malayalam მალაიალამური The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია. No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს. Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად + Click to replace დაწკაპეთ ჩასანაცვლებლად + Show all alternates in the Glyphs panel ყველა ალტერნატივის ჩვენება გლიფების პანელში + Show Character Alternates სიმბოლოს ალტერნატივების ჩვენება Select one character with the Type tool first. ჯერ ტექსტის ხელსაწყოთი მონიშნეთ ერთი სიმბოლო. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} არის {font}-ის გლიფი და არა იმ შრიფტისა, რომლითაც მონიშნული ტექსტია დაწყობილი ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} ეკუთვნის სიმბოლოს {base} (U+{base_code}) {place}; თქვენი მონიშნულია {sel} (U+{code}). @@ -6625,7 +6628,7 @@ axis Y Axis Y ღერძი @plural Result: {n}|Results: {n} შედეგი: {n} @msg select one object მონიშნეთ ერთი ობიექტი @msg Global Edit works on paths, compound paths, symbol instances and groups of them გლობალური რედაქტირება მუშაობს ტრაექტორიებზე, შედგენილ ტრაექტორიებზე, სიმბოლოს ეგზემპლარებსა და მათ ჯგუფებზე -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again მსგავსი ობიექტები ვერ მოიძებნა (შემობრუნებული ან არეკლილი ასლები არ ემთხვევა); შეცვალეთ გლობალური რედაქტირების პარამეტრები და სცადეთ ხელახლა +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again მსგავსი ობიექტები ვერ მოიძებნა (არეკლილი ასლები არ ემთხვევა); შეცვალეთ გლობალური რედაქტირების პარამეტრები და სცადეთ ხელახლა @msg Global Edit is off გლობალური რედაქტირება გამორთულია @msg only an object of the Global Edit other than the one it started from can be left out გლობალური რედაქტირებიდან შეიძლება გამოირიცხოს მხოლოდ ობიექტი, რომლითაც ის არ დაწყებულა @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ან დიაპაზონი, მაგალითად 1, 3-5, და არა {v} @@ -7581,6 +7584,9 @@ font-script Japanese იაპონური Open Tutorial Sample გაკვეთილის ნიმუშის გახსნა Finish Tutorial გაკვეთილის დასრულება Next Tutorial შემდეგი გაკვეთილი + Show Learn Tab სწავლის ჩანართის ჩვენება + Show Skill უნარის ჩვენება + Back One Level ერთი დონით უკან Back to Skills უნარებზე დაბრუნება @msg tutorial `{id}` has no practice sample გაკვეთილს `{id}` არ აქვს სავარჯიშო ნიმუში @msg open the practice sample of `{id}` first (see learn.openSample) ჯერ გახსენით `{id}` გაკვეთილის სავარჯიშო ნიმუში (იხ. learn.openSample) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..04a3234343 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1547,6 +1547,9 @@ antiAlias Sharp 날카롭게 The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다. No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다. Double-click to replace 더블클릭하여 바꾸기 + Click to replace 클릭하여 바꾸기 + Show all alternates in the Glyphs panel 글리프 패널에서 모든 대체 글리프 표시 + Show Character Alternates 문자의 대체 글리프 표시 Select one character with the Type tool first. 먼저 문자 도구로 문자 하나를 선택하세요. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}은(는) {font}의 글리프이며, 선택한 텍스트의 글꼴({text_font})의 것이 아닙니다. {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}은(는) {base}(U+{base_code})의 {place} 글리프입니다. 선택한 문자는 {sel}(U+{code})입니다. @@ -6261,7 +6264,7 @@ axis Y Axis Y축 @plural Result: {n}|Results: {n} 결과: {n} @msg select one object 개체 하나를 선택하십시오 @msg Global Edit works on paths, compound paths, symbol instances and groups of them 전체 편집은 패스, 컴파운드 패스, 심볼 인스턴스 및 이들의 그룹에서 작동합니다 -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 유사한 개체를 찾을 수 없습니다(회전하거나 반사한 복사본은 일치하지 않습니다). 전체 편집 옵션을 변경한 후 다시 시도하십시오 +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again 유사한 개체를 찾을 수 없습니다(반사한 복사본은 일치하지 않습니다). 전체 편집 옵션을 변경한 후 다시 시도하십시오 @msg Global Edit is off 전체 편집이 꺼져 있습니다 @msg only an object of the Global Edit other than the one it started from can be left out 전체 편집을 시작한 개체가 아닌 전체 편집의 개체만 제외할 수 있습니다 @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all 또는 1, 3-5 같은 범위여야 합니다({v}이(가) 아님) @@ -7058,6 +7061,9 @@ breakText Glyphs 글리프 Open Tutorial Sample 튜토리얼 샘플 열기 Finish Tutorial 튜토리얼 완료 Next Tutorial 다음 튜토리얼 + Show Learn Tab 학습 탭 표시 + Show Skill 기술 표시 + Back One Level 한 단계 뒤로 Back to Skills 기술로 돌아가기 @msg tutorial `{id}` has no practice sample 튜토리얼 `{id}`에 연습용 샘플이 없습니다 @msg open the practice sample of `{id}` first (see learn.openSample) 먼저 `{id}`의 연습용 샘플을 여세요(learn.openSample 참조) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..09459378a0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -475,7 +475,7 @@ @msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed de module heeft niet de SHA-256 die `hash` opgeeft: er is niets geïnstalleerd @msg select one object selecteer één object -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again geen vergelijkbare objecten gevonden (gedraaide of gespiegelde kopieën worden niet herkend); wijzig de Opties voor globale bewerking en probeer het opnieuw +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again geen vergelijkbare objecten gevonden (gespiegelde kopieën worden niet herkend); wijzig de Opties voor globale bewerking en probeer het opnieuw @msg Global Edit is off Globale bewerking staat uit @msg only an object of the Global Edit other than the one it started from can be left out alleen een ander object van de globale bewerking dan het object waarmee die begon, kan worden weggelaten @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all of een bereik zoals 1, 3-5, niet {v} @@ -2154,6 +2154,9 @@ digits Malayalam Malayalam The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar. No alternates for the selected character. Geen alternatieven voor het geselecteerde teken. Double-click to replace Dubbelklik om te vervangen + Click to replace Klik om te vervangen + Show all alternates in the Glyphs panel Alle alternatieven tonen in het paneel Glyphs + Show Character Alternates Tekenalternatieven tonen Select one character with the Type tool first. Selecteer eerst één teken met het Type-gereedschap. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} is een glyph van {font}, niet van het lettertype van de geselecteerde tekst ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hoort bij {base} (U+{base_code}) {place}; uw selectie is {sel} (U+{code}). @@ -9318,6 +9321,9 @@ breakText Glyphs Glyphs Open Tutorial Sample Oefenbestand van tutorial openen Finish Tutorial Tutorial voltooien Next Tutorial Volgende tutorial + Show Learn Tab Tabblad van Leren tonen + Show Skill Vaardigheid tonen + Back One Level Eén niveau terug Back to Skills Terug naar vaardigheden @msg tutorial `{id}` has no practice sample tutorial `{id}` heeft geen oefenbestand @msg open the practice sample of `{id}` first (see learn.openSample) open eerst het oefenbestand van `{id}` (zie learn.openSample) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..01c8429004 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1642,6 +1642,9 @@ digits Malayalam Malajalam The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna. No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku. Double-click to replace Kliknij dwukrotnie, aby zastąpić + Click to replace Kliknij, aby zastąpić + Show all alternates in the Glyphs panel Pokaż wszystkie alternatywy w panelu Glify + Show Character Alternates Pokaż alternatywy znaku Select one character with the Type tool first. Najpierw zaznacz jeden znak narzędziem Tekst. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} to glif czcionki {font}, a nie czcionki zaznaczonego tekstu ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} należy do znaku {base} (U+{base_code}) {place}; Twoje zaznaczenie to {sel} (U+{code}). @@ -6529,7 +6532,7 @@ axis Y Axis Oś Y @plural Result: {n}|Results: {n} Wynik: {n}|Wyniki: {n}|Wyniki: {n} @msg select one object zaznacz jeden obiekt @msg Global Edit works on paths, compound paths, symbol instances and groups of them Edycja globalna działa na ścieżkach, ścieżkach złożonych, wystąpieniach symboli i ich grupach -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nie znaleziono podobnych obiektów (obrócone i odbite kopie nie są dopasowywane); zmień opcje edycji globalnej i spróbuj ponownie +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nie znaleziono podobnych obiektów (odbite kopie nie są dopasowywane); zmień opcje edycji globalnej i spróbuj ponownie @msg Global Edit is off Edycja globalna jest wyłączona @msg only an object of the Global Edit other than the one it started from can be left out z edycji globalnej można wykluczyć tylko obiekt inny niż ten, od którego się zaczęła @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all lub zakres, np. 1, 3-5, a nie {v} @@ -7324,6 +7327,9 @@ breakText Glyphs Glify Open Tutorial Sample Otwórz przykład samouczka Finish Tutorial Zakończ samouczek Next Tutorial Następny samouczek + Show Learn Tab Pokaż kartę panelu Nauka + Show Skill Pokaż umiejętność + Back One Level Wróć o jeden poziom Back to Skills Powrót do umiejętności @msg tutorial `{id}` has no practice sample samouczek `{id}` nie ma przykładu do ćwiczeń @msg open the practice sample of `{id}` first (see learn.openSample) najpierw otwórz przykład do ćwiczeń samouczka `{id}` (patrz learn.openSample) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..1255008e2b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -814,6 +814,9 @@ antiAlias Sharp Nítido The selected character's font is not available. A fonte do caractere selecionado não está disponível. No alternates for the selected character. Nenhuma alternativa para o caractere selecionado. Double-click to replace Clique duplo para substituir + Click to replace Clique para substituir + Show all alternates in the Glyphs panel Mostrar todas as alternativas no painel Glifos + Show Character Alternates Mostrar Alternativas de Caractere Select one character with the Type tool first. Primeiro selecione um caractere com a ferramenta Texto. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} é um glifo de {font}, não da fonte do texto selecionado ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertence a {base} (U+{base_code}) {place}; sua seleção é {sel} (U+{code}). @@ -2394,8 +2397,8 @@ digits Malayalam Malaiala Release Opacity Mask Liberar Máscara de Opacidade Release Selection Liberar Seleção Release with Perspective Liberar com Perspectiva - Relink Revinculcar - Relink to Folder… Revinculcar Pasta… + Relink Revincular + Relink to Folder… Revincular Pasta… Each Link Separately Cada Vínculo Separadamente Selected Instances Instâncias Selecionadas All Instances Todas as Instâncias @@ -2404,7 +2407,7 @@ digits Malayalam Malaiala @msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each e paths andam juntos: paths lista entradas com ids e path, no lugar de ids, path e folder @msg paths entry {i} needs path and ids (object ids no other entry has) a entrada {i} de paths precisa de path e ids (ids de objeto que nenhuma outra entrada tenha) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder recebe path: os outros vínculos ausentes são procurados na pasta desse arquivo - Relink… Revinculcar… + Relink… Revincular… Reload Plug-ins Recarregar Plug-ins Remove Remover Remove Anchor Points Remover Pontos de Ancoragem @@ -3963,10 +3966,10 @@ axis Both Ambos @msg `{k}` must be [x, y] `{k}` deve ser [x, y] @msg `{name}` has no options `{name}` não tem opções @msg `{name}` is a built-in workspace; choose another name `{name}` é um espaço de trabalho integrado; escolha outro nome -@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` é uma cor de seleção: os matizes se aplicam a cores globais e especiais +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` é uma cor de processo: os matizes se aplicam a cores globais e especiais @msg `{name}` is no recovery entry name `{name}` não é o nome de uma entrada de recuperação @msg `{name}` isn't a colour swatch `{name}` não é uma amostra de cor -@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white uma cor de seleção não pode colorir uma imagem em escala de cinza com transparência: use uma cor especial, preto ou branco +@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white uma cor de processo não pode colorir uma imagem em escala de cinza com transparência: use uma cor especial, preto ou branco @msg `{path}` must be a bool or an object of bools `{path}` deve ser um booleano ou um objeto de booleanos @msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` não é uma cor (#rrggbb ou o nome de uma predefinição) @msg `{tool}` isn't a Liquify tool `{tool}` não é uma ferramenta de Liquidificar @@ -6589,7 +6592,7 @@ axis Y Axis Eixo Y @plural Result: {n}|Results: {n} Resultado: {n}|Resultados: {n} @msg select one object selecione um objeto @msg Global Edit works on paths, compound paths, symbol instances and groups of them A edição global funciona com demarcadores, demarcadores compostos, instâncias de símbolos e grupos deles -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nenhum objeto semelhante encontrado (cópias giradas ou refletidas não são correspondidas); altere as opções de edição global e tente novamente +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nenhum objeto semelhante encontrado (cópias refletidas não são correspondidas); altere as opções de edição global e tente novamente @msg Global Edit is off A edição global está desativada @msg only an object of the Global Edit other than the one it started from can be left out só é possível deixar fora da edição global um objeto diferente daquele com que ela começou @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ou um intervalo como 1, 3-5, não {v} @@ -7656,6 +7659,9 @@ swatch Yellow Green Verde-amarelado Open Tutorial Sample Abrir Amostra do Tutorial Finish Tutorial Concluir Tutorial Next Tutorial Próximo Tutorial + Show Learn Tab Mostrar Guia de Aprender + Show Skill Mostrar Habilidade + Back One Level Voltar um Nível Back to Skills Voltar às Habilidades @msg tutorial `{id}` has no practice sample o tutorial `{id}` não tem amostra de prática @msg open the practice sample of `{id}` first (see learn.openSample) abra primeiro a amostra de prática de `{id}` (veja learn.openSample) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..26e958353a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -902,6 +902,9 @@ font-technology Variable Вариативные The selected character's font is not available. Шрифт выделенного символа недоступен. No alternates for the selected character. У выделенного символа нет альтернатив. Double-click to replace Дважды щёлкните для замены + Click to replace Щёлкните для замены + Show all alternates in the Glyphs panel Показать все альтернативы в панели «Глифы» + Show Character Alternates Показывать альтернативы символа Select one character with the Type tool first. Сначала выделите один символ инструментом «Текст». {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — глиф шрифта {font}, а не шрифта выделенного текста ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} относится к символу {base} (U+{base_code}) {place}; выделен символ {sel} (U+{code}). @@ -6285,7 +6288,7 @@ axis Y Axis Ось Y @plural Result: {n}|Results: {n} Результат: {n}|Результаты: {n}|Результаты: {n} @msg select one object выберите один объект @msg Global Edit works on paths, compound paths, symbol instances and groups of them Глобальное редактирование работает с контурами, составными контурами, экземплярами символов и их группами -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again похожие объекты не найдены (повёрнутые или отражённые копии не учитываются); измените параметры глобального редактирования и повторите попытку +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again похожие объекты не найдены (отражённые копии не учитываются); измените параметры глобального редактирования и повторите попытку @msg Global Edit is off Глобальное редактирование выключено @msg only an object of the Global Edit other than the one it started from can be left out исключить из глобального редактирования можно только объект, отличный от того, с которого оно началось @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all или диапазон, например 1, 3-5, а не {v} @@ -7556,6 +7559,9 @@ shape Open Открытый Open Tutorial Sample Открыть пример урока Finish Tutorial Завершить урок Next Tutorial Следующий урок + Show Learn Tab Показать вкладку панели «Обучение» + Show Skill Показать навык + Back One Level На уровень назад Back to Skills Назад к навыкам @msg tutorial `{id}` has no practice sample у урока `{id}` нет учебного примера @msg open the practice sample of `{id}` first (see learn.openSample) сначала откройте учебный пример урока `{id}` (см. learn.openSample) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..ae45dc0181 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -891,6 +891,9 @@ font-technology Variable Variabla The selected character's font is not available. Typsnittet för det markerade tecknet är inte tillgängligt. No alternates for the selected character. Inga alternativ för det markerade tecknet. Double-click to replace Dubbelklicka för att ersätta + Click to replace Klicka för att ersätta + Show all alternates in the Glyphs panel Visa alla alternativ i panelen Glyfer + Show Character Alternates Visa teckenalternativ Select one character with the Type tool first. Markera först ett tecken med textverktyget. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} är en glyf i {font}, inte i det typsnitt den markerade texten är satt i ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hör till {base} (U+{base_code}) {place}; din markering är {sel} (U+{code}). @@ -6654,7 +6657,7 @@ axis Y Axis Y-axel @plural Result: {n}|Results: {n} Resultat: {n}|Resultat: {n} @msg select one object markera ett objekt @msg Global Edit works on paths, compound paths, symbol instances and groups of them Global redigering fungerar på banor, sammansatta banor, symbolinstanser och grupper av dem -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again inga liknande objekt hittades (roterade eller speglade kopior matchas inte); ändra alternativen för global redigering och försök igen +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again inga liknande objekt hittades (speglade kopior matchas inte); ändra alternativen för global redigering och försök igen @msg Global Edit is off Global redigering är inte aktiv @msg only an object of the Global Edit other than the one it started from can be left out bara ett annat objekt än det som den globala redigeringen började med kan uteslutas @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all eller ett intervall som 1, 3-5, inte {v} @@ -8905,6 +8908,9 @@ digits Hindi Hindi Open Tutorial Sample Öppna övningsexempel för självstudie Finish Tutorial Slutför självstudie Next Tutorial Nästa självstudie + Show Learn Tab Visa flik i Lär dig + Show Skill Visa färdighet + Back One Level En nivå tillbaka Back to Skills Tillbaka till färdigheter @msg tutorial `{id}` has no practice sample självstudien `{id}` har inget övningsexempel @msg open the practice sample of `{id}` first (see learn.openSample) öppna först övningsexemplet för `{id}` (se learn.openSample) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 602898ecce..230ca2d958 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -334,6 +334,27 @@ fn catalan_reads_as_catalan() { assert_eq!(message(ca, nested), "l’ordre `object.group` no està disponible ara mateix: no hi ha res seleccionat"); } +/// Brazilian Portuguese spells Relink "Revincular", and no row calls a process color a +/// selection color ("cor de seleção"). +#[test] +fn brazilian_portuguese_spells_relink_and_process_color() { + let pt = Lang::from_code("pt-br").expect("pt-br registered"); + let (entries, _) = parse_entries(pt.0.source); + let mut wrong = Vec::new(); + for (_, src, translation) in &entries { + let portuguese = translation.to_lowercase(); + if portuguese.contains("revinculcar") { + wrong.push(format!("Relink in {src:?} -> {translation:?}")); + } + if src.to_lowercase().contains("process colo") && (portuguese.contains("cor de seleção") || portuguese.contains("cores de seleção")) { + wrong.push(format!("process color in {src:?} -> {translation:?}")); + } + } + assert!(wrong.is_empty(), "{} Brazilian Portuguese rows are wrong:\n{}", wrong.len(), wrong.join("\n")); + assert_eq!(tr(pt, "Relink…"), "Revincular…"); + assert!(message(pt, "`{name}` is a process colour: tints apply to global and spot colours").to_lowercase().contains("cor de processo")); +} + #[test] fn font_height_reference_labels_are_in_complete_catalogs() { for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..a193574ca7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -883,6 +883,9 @@ font-technology Variable Değişken The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor. No alternates for the selected character. Seçili karakter için alternatif yok. Double-click to replace Değiştirmek için çift tıklayın + Click to replace Değiştirmek için tıklayın + Show all alternates in the Glyphs panel Tüm alternatifleri Glifler panelinde göster + Show Character Alternates Karakter Alternatiflerini Göster Select one character with the Type tool first. Önce Yazım aracıyla bir karakter seçin. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}, seçili metnin yazı tipine ({text_font}) değil, {font} yazı tipine ait bir glif. {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}, {base} (U+{base_code}) karakterine aittir ({place}); seçiminiz {sel} (U+{code}). @@ -6747,7 +6750,7 @@ axis Y Axis Y ekseni @plural Result: {n}|Results: {n} Sonuç: {n} @msg select one object bir nesne seçin @msg Global Edit works on paths, compound paths, symbol instances and groups of them Genel düzenleme yollar, bileşik yollar, sembol örnekleri ve bunların grupları üzerinde çalışır -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again benzer nesne bulunamadı (döndürülmüş veya yansıtılmış kopyalar eşleşmez); Genel Düzenleme Seçeneklerini değiştirip yeniden deneyin +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again benzer nesne bulunamadı (yansıtılmış kopyalar eşleşmez); Genel Düzenleme Seçeneklerini değiştirip yeniden deneyin @msg Global Edit is off Genel düzenleme kapalı @msg only an object of the Global Edit other than the one it started from can be left out yalnızca genel düzenlemenin başladığı nesne dışındaki bir nesne düzenlemenin dışında bırakılabilir @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ya da 1, 3-5 gibi bir aralık olmalı, {v} değil @@ -7540,6 +7543,9 @@ breakText Glyphs Glifler Open Tutorial Sample Öğretici Örneğini Aç Finish Tutorial Öğreticiyi Bitir Next Tutorial Sonraki Öğretici + Show Learn Tab Öğren Sekmesini Göster + Show Skill Beceriyi Göster + Back One Level Bir Düzey Geri Back to Skills Becerilere Dön @msg tutorial `{id}` has no practice sample `{id}` öğreticisinin alıştırma örneği yok @msg open the practice sample of `{id}` first (see learn.openSample) önce `{id}` öğreticisinin alıştırma örneğini açın (bkz. learn.openSample) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..31c15f10b5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -906,6 +906,9 @@ font-technology Variable Варіативні The selected character's font is not available. Шрифт виділеного символу недоступний. No alternates for the selected character. Виділений символ не має альтернатив. Double-click to replace Двічі клацніть для заміни + Click to replace Клацніть для заміни + Show all alternates in the Glyphs panel Показати всі альтернативи на панелі «Гліфи» + Show Character Alternates Показувати альтернативи символу Select one character with the Type tool first. Спершу виділіть один символ інструментом «Текст». {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — гліф шрифту {font}, а не шрифту виділеного тексту ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} належить символу {base} (U+{base_code}) {place}; виділено символ {sel} (U+{code}). @@ -6280,7 +6283,7 @@ axis Y Axis Вісь Y @plural Result: {n}|Results: {n} Результат: {n}|Результати: {n}|Результати: {n} @msg select one object виберіть один об’єкт @msg Global Edit works on paths, compound paths, symbol instances and groups of them Глобальне редагування працює з контурами, складеними контурами, екземплярами символів та їхніми групами -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again схожих об’єктів не знайдено (повернуті чи віддзеркалені копії не враховуються); змініть параметри глобального редагування та спробуйте ще раз +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again схожих об’єктів не знайдено (віддзеркалені копії не враховуються); змініть параметри глобального редагування та спробуйте ще раз @msg Global Edit is off Глобальне редагування вимкнено @msg only an object of the Global Edit other than the one it started from can be left out виключити з глобального редагування можна лише об’єкт, відмінний від того, з якого воно почалося @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all або діапазон, наприклад 1, 3-5, а не {v} @@ -7077,6 +7080,9 @@ breakText Glyphs Гліфи Open Tutorial Sample Відкрити приклад уроку Finish Tutorial Завершити урок Next Tutorial Наступний урок + Show Learn Tab Показати вкладку панелі «Навчання» + Show Skill Показати навичку + Back One Level На рівень назад Back to Skills Назад до навичок @msg tutorial `{id}` has no practice sample урок `{id}` не має навчального прикладу @msg open the practice sample of `{id}` first (see learn.openSample) спочатку відкрийте навчальний приклад уроку `{id}` (див. learn.openSample) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..30e42d6b53 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1065,6 +1065,9 @@ font-technology Variable Biến thiên The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng. No alternates for the selected character. Ký tự đã chọn không có glyph thay thế. Double-click to replace Bấm đúp để thay thế + Click to replace Bấm để thay thế + Show all alternates in the Glyphs panel Hiện tất cả glyph thay thế trong bảng Glyph + Show Character Alternates Hiện glyph thay thế của ký tự Select one character with the Type tool first. Trước hết hãy chọn một ký tự bằng công cụ Chữ. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} là glyph của {font}, không phải của phông chữ mà văn bản đã chọn đang dùng ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} thuộc về {base} (U+{base_code}) {place}; ký tự bạn chọn là {sel} (U+{code}). @@ -6281,7 +6284,7 @@ axis Y Axis Trục Y @plural Result: {n}|Results: {n} Kết quả: {n} @msg select one object hãy chọn một đối tượng @msg Global Edit works on paths, compound paths, symbol instances and groups of them Chỉnh sửa toàn cục hoạt động trên đường dẫn, đường dẫn phức hợp, phiên bản biểu tượng và nhóm của chúng -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again không tìm thấy đối tượng tương tự (các bản sao đã xoay hoặc lật không được tính); hãy thay đổi tùy chọn chỉnh sửa toàn cục rồi thử lại +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again không tìm thấy đối tượng tương tự (các bản sao đã lật không được tính); hãy thay đổi tùy chọn chỉnh sửa toàn cục rồi thử lại @msg Global Edit is off Chỉnh sửa toàn cục đang tắt @msg only an object of the Global Edit other than the one it started from can be left out chỉ có thể loại khỏi chỉnh sửa toàn cục một đối tượng khác với đối tượng mà nó bắt đầu @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all hoặc một phạm vi như 1, 3-5, không phải {v} @@ -7078,6 +7081,9 @@ breakText Glyphs Glyph Open Tutorial Sample Mở tệp mẫu của hướng dẫn Finish Tutorial Hoàn tất hướng dẫn Next Tutorial Hướng dẫn tiếp theo + Show Learn Tab Hiện thẻ của Học + Show Skill Hiện kỹ năng + Back One Level Quay lại một cấp Back to Skills Quay lại kỹ năng @msg tutorial `{id}` has no practice sample hướng dẫn `{id}` không có tệp mẫu thực hành @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) @@ -7118,3 +7124,120 @@ breakText Glyphs Glyph Grommets Khoen Grommet Inset Khoảng cách từ mép đến khoen Diameter Đường kính + +# 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 Hiệu ứng 3D yêu cầu tọa độ hữu hạn trong phạm vi 100 triệu điểm +@msg 3D effects require one to ten lights Hiệu ứng 3D yêu cầu từ một đến mười nguồn sáng +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Độ sâu BMP {_1}: chỉ hỗ trợ 1, 4, 8, 16, 24 hoặc 32 bit mỗi điểm ảnh +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Mã hóa BMP thất bại: {width} × {height} điểm ảnh không vừa với định dạng hoặc bộ đệm điểm ảnh +@msg GIF encoding failed: the palette image is malformed Mã hóa GIF thất bại: ảnh bảng màu bị sai định dạng +@msg GIF encoding failed: {e} Mã hóa GIF thất bại: {e} +@msg JPEG colour profile: {e} Hồ sơ màu JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Mã hóa JPEG thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg JPEG encoding failed: {e} Mã hóa JPEG thất bại: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Mã hóa PNG thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg PNG encoding failed: the palette image is malformed Mã hóa PNG thất bại: ảnh bảng màu bị sai định dạng +@msg PNG encoding failed: {e} Mã hóa PNG thất bại: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Mã hóa PNG thất bại: {width} × {height} điểm ảnh không phải là kích thước ảnh hợp lệ +@msg PSD colour profile: {e} Hồ sơ màu PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Mã hóa PSD thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg Revolve profile contains invalid coordinates Biên dạng Xoay tròn chứa tọa độ không hợp lệ +@msg Revolve profile is too complex Biên dạng Xoay tròn quá phức tạp +@msg Revolve projection is not finite Phép chiếu của Xoay tròn không hữu hạn +@msg Revolve requires finite profile coordinates within 100 million points Xoay tròn yêu cầu tọa độ biên dạng hữu hạn trong phạm vi 100 triệu điểm +@msg Revolve surface is too complex; reduce Segments or simplify the profile Bề mặt Xoay tròn quá phức tạp; hãy giảm Đoạn hoặc đơn giản hóa biên dạng +@msg Revolve surface is too complex; simplify the profile Bề mặt Xoay tròn quá phức tạp; hãy đơn giản hóa biên dạng +@msg Revolve visibility cannot split the projected boundary Tính toán phần nhìn thấy của Xoay tròn không thể tách đường biên đã chiếu +@msg Revolve visibility creates too many fragments Tính toán phần nhìn thấy của Xoay tròn tạo ra quá nhiều mảnh +@msg Revolve visibility creates too many vector anchors Tính toán phần nhìn thấy của Xoay tròn tạo ra quá nhiều điểm neo vectơ +@msg Revolve visibility exceeds its geometry work limit Tính toán phần nhìn thấy của Xoay tròn vượt quá giới hạn khối lượng tính toán hình học +@msg Revolve visibility exceeds its spatial index limit Tính toán phần nhìn thấy của Xoay tròn vượt quá giới hạn chỉ mục không gian +@msg Revolve visibility has too many boundary crossings Tính toán phần nhìn thấy của Xoay tròn có quá nhiều điểm cắt đường biên +@msg Revolve visibility has too many input anchors Tính toán phần nhìn thấy của Xoay tròn có quá nhiều điểm neo đầu vào +@msg Revolve visibility requires convex cap sectors Tính toán phần nhìn thấy của Xoay tròn yêu cầu các hình quạt nắp lồi +@msg Revolve visibility requires finite projected faces Tính toán phần nhìn thấy của Xoay tròn yêu cầu các mặt chiếu hữu hạn +@msg SVG options: {e} Tùy chọn SVG: {e} +@msg Save for Web settings: {e} Cài đặt Lưu cho Web: {e} +@msg TIFF colour profile: {e} Hồ sơ màu TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Mã hóa TIFF thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg TIFF encoding failed: {e} Mã hóa TIFF thất bại: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Độ sâu Targa {_1}: chỉ hỗ trợ 16, 24 hoặc 32 bit mỗi điểm ảnh +@msg Targa encoding failed: the pixel buffer doesn't match the image size Mã hóa Targa thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg The 3D projection is not finite Phép chiếu 3D không hữu hạn +@msg The 3D shadow is too complex; turn Shadows off or simplify the art Bóng đổ 3D quá phức tạp; hãy tắt Bóng đổ hoặc đơn giản hóa hình vẽ +@msg The shape contains invalid coordinates Hình dạng chứa tọa độ không hợp lệ +@msg Too many Revolve faces for visibility evaluation Quá nhiều mặt Xoay tròn để tính toán phần nhìn thấy +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft đọc tối đa {ASE_MAX_ENTRIES} màu và nhóm từ một tệp trao đổi mẫu màu, còn thư viện này có {entries} +@msg WebP encoding failed: {e} Mã hóa WebP thất bại: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `index` must be a whole number `index` phải là số nguyên +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` phải là smallThumb, mediumThumb, largeThumb, smallList hoặc largeList +@msg a line can't run from a point to itself một đường không thể đi từ một điểm đến chính nó +@msg a line needs at least two points một đường cần ít nhất hai điểm +@msg a number cannot be read không thể đọc một số +@msg a swatch exchange block is too long một khối trong tệp trao đổi mẫu màu quá dài +@msg a swatch exchange name is too long một tên trong tệp trao đổi mẫu màu quá dài +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels không thể đổi kích thước ảnh {sw} × {sh} thành {width} × {height} điểm ảnh +@msg a {width} × {height} image is too large ảnh {width} × {height} quá lớn +@msg a {width} × {height} image: {e} ảnh {width} × {height}: {e} +@msg an image could not be written: {e} không thể ghi một ảnh: {e} +@msg an image that couldn't be read was left out một ảnh không đọc được đã bị bỏ qua +@msg can't open “{name}”: {e} không thể mở “{name}”: {e} +@msg cannot align zip entries to {align} bytes không thể căn các mục zip theo {align} byte +@msg cannot open the automation {kind} root `{_1}`: {e} không thể mở thư mục gốc {kind} tự động `{_1}`: {e} +@msg cannot read profile: {0} không thể đọc hồ sơ màu: {0} +@msg color book version {version} isn't supported (only 1) không hỗ trợ sổ màu phiên bản {version} (chỉ 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) không hỗ trợ sổ màu trong không gian màu {other} (chỉ RGB, CMYK và Lab) +@msg file name too long to zip: {_1} tên tệp quá dài để nén zip: {_1} +@msg file name too long to zip: {name} tên tệp quá dài để nén zip: {name} +@msg freeform line {i}: {e} đường tự do {i}: {e} +@msg freeform point {i}: {e} điểm tự do {i}: {e} +@msg give `{key}` or `{other}`, not both hãy cung cấp `{key}` hoặc `{other}`, không phải cả hai +@msg grayscale profiles can't be working spaces yet hồ sơ màu thang xám chưa thể dùng làm không gian làm việc +@msg input contains NaN or infinite coordinates đầu vào chứa tọa độ NaN hoặc vô hạn +@msg input path could not be closed không thể đóng đường dẫn đầu vào +@msg invalid ICC profile: {0} hồ sơ ICC không hợp lệ: {0} +@msg invalid scale {scale} tỷ lệ không hợp lệ {scale} +@msg its localized names take more than {_1} KB các tên bản địa hóa của nó chiếm hơn {_1} KB +@msg its objects are nested too deep ({e}) các đối tượng của nó lồng nhau quá sâu ({e}) +@msg its tag table lists the `{_1}` tag more than once bảng thẻ của nó liệt kê thẻ `{_1}` nhiều hơn một lần +@msg not a graphic style library: {e} không phải thư viện kiểu đồ họa: {e} +@msg not a swatch library (.vcswatches or .gpl) không phải thư viện mẫu màu (.vcswatches hoặc .gpl) +@msg not a swatch library (format `{_1}`) không phải thư viện mẫu màu (định dạng `{_1}`) +@msg not a swatch library: {e} không phải thư viện mẫu màu: {e} +@msg not an action set file ({e}) không phải tệp bộ hành động ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray không gian màu {other} của hồ sơ không phải RGB, CMYK hoặc Gray +@msg regular expression too large biểu thức chính quy quá lớn +@msg script file is not UTF-8 text: {value} tệp tập lệnh không phải văn bản UTF-8: {value} +@msg stop {i}: {e} điểm dừng {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) không hỗ trợ tệp trao đổi mẫu màu phiên bản {major}.{minor} (chỉ 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors sổ màu chứa hơn {ASE_MAX_ENTRIES} màu +@msg the color book is cut short sổ màu bị cắt ngắn +@msg the color book's names take more than {_1} MB các tên trong sổ màu chiếm hơ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 ảnh sẽ có kích thước {w} × {h} điểm ảnh; xuất raster bị giới hạn ở {MAX_RASTER_SIDE} điểm ảnh mỗi cạnh và {_1} megapixel: hãy giảm tỷ lệ hoặc độ phân giải +@msg the optimised image can't be read back: {e} không thể đọc lại ảnh đã tối ưu hóa: {e} +@msg the pixel buffer doesn't match the image size bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg the preview: {e} bản xem trước: {e} +@msg the request may not read the New Document profiles folder yêu cầu không được phép đọc thư mục hồ sơ Tài liệu mới +@msg the shapes are too degenerate to combine các hình dạng quá suy biến để kết hợp +@msg the shapes cross themselves and each other too often to combine các hình dạng tự cắt và cắt nhau quá nhiều lần để kết hợp +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups tệp trao đổi mẫu màu chứa hơn {ASE_MAX_ENTRIES} màu và nhóm +@msg the swatch exchange file is cut short tệp trao đổi mẫu màu bị cắt ngắn +@msg the {_1} plate can't be read: {e} không thể đọc bản in {_1}: {e} +@msg this binary DXF file is damaged or cut short tệp DXF nhị phân này bị hỏng hoặc bị cắt ngắn +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that đây là bản vẽ DWG, không thể đọc được: hãy lưu nó dưới dạng DXF từ ứng dụng CAD rồi mở tệp đó +@msg too many classes quá nhiều lớp ký tự +@msg too many groups quá nhiều nhóm +@msg too many layers for PSD (at most 32767) quá nhiều lớp cho PSD (tối đa 32767) +@msg too many swatch exchange blocks quá nhiều khối trong tệp trao đổi mẫu màu +@msg unknown colour profile: {0} hồ sơ màu không xác định: {0} +@msg {_1} clashes with a built-in profile name {_1} trùng với tên hồ sơ màu có sẵn +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} điểm ảnh quá lớn cho GIF (tối đa 65535 điểm ảnh mỗi cạnh): hãy giảm độ phân giải +@msg {_1}: no usable transform to/from sRGB {_1}: không có phép chuyển đổi dùng được sang/từ sRGB +@msg {name} is not a CMYK profile {name} không phải hồ sơ màu CMYK +@msg {name} is not an RGB profile {name} không phải hồ sơ màu RGB +@msg {path}: could not be read {path}: không thể đọc +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} điểm ảnh quá lớn cho JPEG (tối đa 65535 điểm ảnh mỗi cạnh): hãy giảm độ phân giải +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} điểm ảnh quá lớn cho Targa (tối đa 65535 điểm ảnh mỗi cạnh): hãy giảm độ phân giải +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} điểm ảnh quá lớn cho PSD (tối đa {MAX_SIDE} điểm ảnh mỗi cạnh): hãy giảm độ phân giải diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..9d5fe1c4b6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -846,6 +846,9 @@ antiAlias Sharp 锐利 The selected character's font is not available. 所选字符的字体不可用。 No alternates for the selected character. 所选字符没有替代字。 Double-click to replace 双击以替换 + Click to replace 单击以替换 + Show all alternates in the Glyphs panel 在“字形”面板中显示所有替代字 + Show Character Alternates 显示字符的替代字 Select one character with the Type tool first. 请先用文字工具选择一个字符。 {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所选文本所用字体({text_font})的字形。 {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 属于 {base}(U+{base_code}){place};您选择的是 {sel}(U+{code})。 @@ -6513,7 +6516,7 @@ axis Y Axis Y 轴 @plural Result: {n}|Results: {n} 结果:{n} @msg select one object 请选择一个对象 @msg Global Edit works on paths, compound paths, symbol instances and groups of them 全局编辑适用于路径、复合路径、符号实例及其编组 -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 未找到相似对象(旋转或镜像的副本不会匹配);请更改全局编辑选项后重试 +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again 未找到相似对象(镜像的副本不会匹配);请更改全局编辑选项后重试 @msg Global Edit is off 全局编辑未开启 @msg only an object of the Global Edit other than the one it started from can be left out 只能从全局编辑中排除起始对象以外的对象 @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`:应为 all 或 1, 3-5 这样的范围,而不是 {v} @@ -7306,6 +7309,9 @@ breakText Glyphs 字形 Open Tutorial Sample 打开教程示例 Finish Tutorial 完成教程 Next Tutorial 下一个教程 + Show Learn Tab 显示学习选项卡 + Show Skill 显示技能 + Back One Level 返回上一级 Back to Skills 返回技能 @msg tutorial `{id}` has no practice sample 教程 `{id}` 没有练习示例 @msg open the practice sample of `{id}` first (see learn.openSample) 请先打开 `{id}` 的练习示例 (请参阅 learn.openSample) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..528e0a57ca 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -875,6 +875,9 @@ font-technology Variable 可變 The selected character's font is not available. 選取字元的字體無法使用。 No alternates for the selected character. 選取的字元沒有替代字。 Double-click to replace 按兩下以取代 + Click to replace 按一下以取代 + Show all alternates in the Glyphs panel 在「字符」面板中顯示所有替代字 + Show Character Alternates 顯示字元的替代字 Select one character with the Type tool first. 請先用文字工具選取一個字元。 {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所選文字所用字體({text_font})的字形。 {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 屬於 {base}(U+{base_code}){place};您選取的是 {sel}(U+{code})。 @@ -6320,7 +6323,7 @@ axis Y Axis Y 軸 @plural Result: {n}|Results: {n} 結果:{n} @msg select one object 請選取一個物件 @msg Global Edit works on paths, compound paths, symbol instances and groups of them 整體編輯適用於路徑、複合路徑、符號實體及其群組 -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 找不到相似的物件(旋轉或鏡像的副本不會相符);請變更整體編輯選項後再試一次 +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again 找不到相似的物件(鏡像的副本不會相符);請變更整體編輯選項後再試一次 @msg Global Edit is off 整體編輯未開啟 @msg only an object of the Global Edit other than the one it started from can be left out 只能從整體編輯中排除起始物件以外的物件 @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`:應為 all 或 1, 3-5 這類範圍,而不是 {v} @@ -7114,6 +7117,9 @@ breakText Glyphs 字符 Open Tutorial Sample 開啟教學課程範例 Finish Tutorial 完成教學課程 Next Tutorial 下一個教學課程 + Show Learn Tab 顯示學習索引標籤 + Show Skill 顯示技能 + Back One Level 返回上一層 Back to Skills 返回技能 @msg tutorial `{id}` has no practice sample 教學課程 `{id}` 沒有練習範例 @msg open the practice sample of `{id}` first (see learn.openSample) 請先開啟 `{id}` 的練習範例 (請參閱 learn.openSample) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 52c8c2e7bf..21b8795662 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -669,18 +669,36 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} 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.tab", + "Show Learn Tab", + "", + "{tab: forYou|skills} show the Learn panel on its For you or Skills tab, as the tab buttons do; the Skills tab keeps the skill and tutorial it had open; a missing, non-string or unknown tab is an error and changes nothing; returns {tab, skill, tutorial}, with null for no skill or no tutorial open", + ), ( "learn.skills", "Back to Skills", "", "{} show the Learn panel on its Skills tab with the list of skills, with no skill and no tutorial open, as Back to skills after a skill's last tutorial and the Quick skill guide card do; returns what learn.list returns", ), + ( + "learn.skill", + "Show Skill", + "", + "{skill: id (see learn.list)} show the Learn panel on its Skills tab with that skill's tutorials, as choosing the skill's row does; a missing, non-string or unknown id is an error and changes nothing; returns the skill as learn.list lists it: {id, title, done, total, tutorials: [{id, title, steps, finished, completedSteps}]}", + ), ( "learn.show", "Show Tutorial", "", "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", ), + ( + "learn.back", + "Back One Level", + "", + "{} show the Learn panel and go up one level on its Skills tab, as its Back button does: from a tutorial's steps to its skill's tutorials, and from a skill's tutorials to the list of skills; on the list of skills or the For you tab the view stays as it is; returns {tab, skill, tutorial}, as learn.tab does", + ), ( "learn.locateTool", "Show Tool Location", diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index cdeafd2b00..adaf523e53 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -651,6 +651,8 @@ fn reset_panel_closes_only_its_own_undo_group() { app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); assert_eq!(app.session.active_index(), Some(1)); let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone(); + // A reset that changes nothing records no step, so B starts away from the panel's defaults. + app.run("text.setFormat", json!({"id": b, "antiAlias": "crisp"})).unwrap(); app.session.set_active(0); app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); app.run("text.setFormat", json!({"id": a, "antiAlias": "none"})).unwrap(); diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 7c0a18d14f..2a3251b012 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -564,6 +564,129 @@ fn apply_alternate(app: &mut VectorcraftApp, id: NodeId, bytes: (usize, usize), } } +/// The most alternate glyphs the canvas shows beside a selected character. Public behavior: at +/// most five alternates show on the canvas, and an icon to their right opens the Glyphs panel when +/// the font has more ("Replace a character with on-canvas alternate glyphs", +/// ). +pub(crate) const ON_CANVAS_ALTERNATES: usize = 5; + +/// The alternate glyphs the canvas offers beside the Type tool's one selected character +/// ([`on_canvas`]). +pub(crate) struct OnCanvas { + /// The text the character is in. + pub id: NodeId, + /// The character's bytes in the text, start and end. + pub bytes: (usize, usize), + /// The face that draws the character's glyph. + face: std::sync::Arc, + /// Up to [`ON_CANVAS_ALTERNATES`] glyphs, none of them the glyph the character is drawn with. + cells: Vec, + /// Whether the character has alternates that [`Self::cells`] leaves out. + pub more: bool, + features: Vec, + has_choice: bool, +} + +impl OnCanvas { + /// How many glyphs are offered: 1 to [`ON_CANVAS_ALTERNATES`]. + pub fn count(&self) -> usize { + self.cells.len() + } + + /// The id of glyph `i`. + #[cfg(test)] + pub fn gid(&self, i: usize) -> Option { + self.cells.get(i).map(|c| c.gid) + } + + /// The preview of glyph `i` in a square of `px` pixels. + pub fn texture(&self, ctx: &egui::Context, i: usize, px: u32) -> Option { + self.cells.get(i).map(|c| glyph_texture(ctx, &self.face, c.gid, px)) + } + + /// The tooltip of glyph `i`: its id, the feature that draws it, and what a click does. + pub fn tip(&self, i: usize) -> String { + let Some(c) = self.cells.get(i) else { return String::new() }; + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &c.gid.to_string())]); + let glyph = match (&c.apply, c.default) { + (_, true) => glyph, + (Some(tag), _) => format!("{glyph} ({tag})"), + (None, _) => format!("{glyph} ({})", String::from_utf8_lossy(&c.feature)), + }; + let mut tip = format!("{glyph}\n{}", tl!("Click to replace")); + if !c.default && c.apply.is_none() { + tip.push('\n'); + tip.push_str(tl!("No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors.")); + } + tip + } + + /// Draw the character with glyph `i`, as a double-click on that glyph in Show › Alternates for + /// Current Selection does: one command, so one undo step. + pub fn pick(&self, app: &mut VectorcraftApp, i: usize) { + let Some(c) = self.cells.get(i) else { return }; + let pick = match (&c.apply, c.default) { + (_, true) => Pick::Default, + (Some(tag), _) => Pick::Feature(tag.clone()), + (None, _) => Pick::Glyph(c.gid), + }; + super::character::end_typing(app); + apply_alternate(app, self.id, self.bytes, &self.features, self.has_choice, pick); + } +} + +/// What the canvas offers beside the Type tool's selection: the alternates of the selected +/// character as it stands in its word ([`alternates_found`], found once for each document +/// revision), in the font's order and without the glyph the character is drawn with. A character +/// drawn with an alternate gets its default glyph first. `None` unless the Type tool's selection +/// is exactly one character of one type object whose font is available and gives it another glyph. +pub(crate) fn on_canvas(app: &VectorcraftApp, ui: &Ui) -> Option { + let (_, a, b) = super::character::text_editing(app)?; + if a == b || super::character::text_story(app).is_some() { + return None; + } + let target = selected_target(app)?; + let (start, end) = (a.min(b), a.max(b)); + if target.byte != start || start + target.ch.len_utf8() != end { + return None; + } + let db = FontDb::global(); + let face = db.style_face(target.style).filter(|_| db.has_family(&target.style.font_family))?; + let found = alternates_found(app, ui, target.id, start, face.id())?; + let face = db.face_by_id(found.font_id).unwrap_or(face); + let default = (found.default != found.current).then_some(Variant { gid: found.default, feature: [0; 4], apply: None, default: true }); + let others = found.alternates.iter().filter(|a| a.gid != found.default && a.gid != found.current).map(|a| Variant { + gid: a.gid, + feature: a.feature, + apply: a.apply.clone(), + default: false, + }); + let mut cells: Vec = default.into_iter().chain(others).collect(); + if cells.is_empty() { + return None; + } + let more = cells.len() > ON_CANVAS_ALTERNATES; + cells.truncate(ON_CANVAS_ALTERNATES); + Some(OnCanvas { + id: target.id, + bytes: (start, end), + face, + cells, + more, + features: target.style.features.clone(), + has_choice: target.style.glyph_alternate.is_some(), + }) +} + +/// Show the Glyphs panel on Show › Alternates for Current Selection, for the canvas's control that +/// stands for the alternates it leaves out. A panel that is already shown stays where it is. +pub(crate) fn show_alternates(app: &mut VectorcraftApp, ctx: &egui::Context) { + set_pstate(ctx, "gl-subset", SUBSETS.len()); + if let Err(e) = crate::panel_docking::reveal_panel(app, "glyphs") { + app.status(e); + } +} + /// The Type tool's selected character, and what its word offers, for a double-click on a glyph no /// character maps in Show › Entire Font ([`Fit::pick`]). struct Fit { diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index b898ce8bf8..685da495e4 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -81,7 +81,9 @@ pub(super) fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) { } /// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift -/// (highlight the rows from the last one clicked down to it). +/// (highlight the rows from the last one clicked down to it). A plain click also lets go of the +/// selected ruler guides, keeping the selected art (#7340): a guide's row picks it the way a +/// highlight picks a row, so picking another row alone drops it. pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) { let Some((id, m)) = out.click.take() else { return }; let anchor: Option = ui.data(|d| d.get_temp(key("anchor"))); @@ -95,6 +97,10 @@ pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) { widgets::apply_row_click(&mut rows, &click); out.rows_after_click = Some(rows); let ids = |rows: &[NodeId]| rows.iter().map(|r| r.0).collect::>(); + if matches!(click, widgets::RowClick::Only(_)) && !view.guides.is_empty() { + // Toggled out of the selection, so the selected art stays. + out.actions.push(("guide.select".into(), json!({"indexes": view.guides, "toggle": true}))); + } out.actions.push(match click { widgets::RowClick::Only(id) => ("layer.setCurrent".into(), json!({"id": id.0})), widgets::RowClick::Toggle(id) => ("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"})), diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index c072a45828..ae058ff348 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -987,6 +987,48 @@ fn guide_rows_are_deleted_with_the_trash_and_delete() { assert!(kept(&app)); } +/// A plain click on another row lets go of the guide a guide row selected, keeping the selected +/// art, so a guide can be dropped without Ctrl/Cmd-clicking its own row (#7340); a Ctrl/Cmd click +/// on a row adds to the pick and keeps it. +#[test] +fn a_plain_row_click_lets_go_of_the_selected_guide() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap()); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 1, its guide (no target circle), the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 2); + let guide_row = egui::pos2(150.0, (circles[0].y + circles[1].y) / 2.0); + let rect_row = egui::pos2(150.0, circles[1].y); + let guides = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides) + }; + let art = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); + // Guide, then the rectangle's row: the guide is let go and the rectangle's row highlighted. + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + assert_eq!(guides(&app), [0]); + click_with(&mut app, &ctx, rect_row, egui::Modifiers::NONE); + assert!(guides(&app).is_empty(), "a plain row click drops the guide"); + assert_eq!(app.session.active().unwrap().highlighted_rows(), [rect]); + // Art and a guide selected together: a plain row click drops only the guide. + run(&mut app, "select.all", json!({})); + click_with(&mut app, &ctx, guide_row, egui::Modifiers::SHIFT); + assert_eq!((guides(&app), art(&app)), (vec![0], vec![rect])); + click_with(&mut app, &ctx, egui::pos2(150.0, circles[0].y), egui::Modifiers::NONE); + assert!(guides(&app).is_empty()); + assert_eq!(art(&app), [rect], "the selected art stays"); + // A Ctrl/Cmd click on a row adds that row to the highlight and keeps the guide. + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + click_with(&mut app, &ctx, rect_row, egui::Modifiers::COMMAND); + assert_eq!(guides(&app), [0], "a Ctrl/Cmd row click keeps the guide"); +} + /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows /// holding selected art). fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index a4f2cd52e4..82e139e25c 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -8,8 +8,9 @@ //! 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, +//! skills clears it. `learn.list` lists the tutorials with their progress, `learn.tab` chooses a +//! tab, `learn.skills` shows the list of skills, `learn.skill` opens one skill, `learn.show` opens +//! one tutorial, `learn.back` goes up one level, `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 @@ -52,14 +53,23 @@ pub struct Practice { pub opened: Vec<&'static str>, } +/// Each tab with the name `learn.tab` takes for it. +const TABS: [(Tab, &str); 2] = [(Tab::ForYou, "forYou"), (Tab::Skills, "skills")]; + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let mut chosen = None; ui.horizontal(|ui| { - for (tab, label) in [(Tab::ForYou, tl!("For you")), (Tab::Skills, tl!("Skills"))] { - if ui.selectable_label(app.ui.learn.tab == tab, label).clicked() { - app.ui.learn.tab = tab; + for ((tab, name), label) in TABS.into_iter().zip([tl!("For you"), tl!("Skills")]) { + if ui.selectable_label(app.ui.learn.tab == tab, label).clicked() && app.ui.learn.tab != tab { + chosen = Some(name); } } }); + if let Some(name) = chosen + && let Err(e) = app.run("learn.tab", json!({ "tab": name })) + { + app.status(e); + } crate::widgets::divider(ui); match app.ui.learn.tab { Tab::ForYou => for_you(app, ui), @@ -113,45 +123,47 @@ pub(crate) fn guide_card(app: &mut VectorcraftApp, ui: &mut Ui, id: egui::Id, fi /// The Skills tab: the skills, a skill's tutorials, or a tutorial's steps. fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { + let mut run = None; let open = app.ui.learn.tutorial.and_then(catalog::tutorial); - if let Some((skill, tutorial)) = open { + if let Some((_, tutorial)) = open { if back(ui) { - app.ui.learn.skill = Some(skill.id); - app.ui.learn.tutorial = None; + run = Some(("learn.back", json!({}))); } steps(app, ui, tutorial); practice(app, ui, tutorial); - return; - } - if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { + } else if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { if back(ui) { - app.ui.learn.skill = None; + run = Some(("learn.back", json!({}))); } crate::widgets::subheader(ui, skill.title); for tutorial in skill.tutorials { if row(ui, tutorial.title, Mark::Check(app.ui.learn_progress.is_finished(tutorial.id))).clicked() { - app.ui.learn.tutorial = Some(tutorial.id); + run = Some(("learn.show", json!({ "tutorial": tutorial.id }))); } } - return; - } - for skill in catalog::SKILLS { - 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); + } else { + for skill in catalog::SKILLS { + let ring = Mark::Ring { done: app.ui.learn_progress.done(skill), total: skill.tutorials.len() }; + if row(ui, skill.title, ring).clicked() { + run = Some(("learn.skill", json!({ "skill": skill.id }))); + } + } + if !app.ui.learn_progress.is_empty() { + ui.add_space(6.0); + if ui.button(tl!("Reset progress")).clicked() { + run = Some(("learn.resetProgress", json!({}))); + } } } - 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); - } + if let Some((cmd, params)) = run + && let Err(e) = app.run(cmd, params) + { + app.status(e); } } -/// The Back button above a skill's tutorials and a tutorial's steps; true when it was clicked. +/// The Back button above a skill's tutorials and a tutorial's steps (`learn.back`); true when it +/// was clicked. fn back(ui: &mut Ui) -> bool { crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() } @@ -351,15 +363,18 @@ fn check(painter: &egui::Painter, t: &Tokens, center: egui::Pos2, finished: bool 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.markStep`, `learn.finish`, `learn.next` and -/// `learn.resetProgress`. +/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.tab`, `learn.skills`, +/// `learn.skill`, `learn.show`, `learn.back`, `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(&app.ui.learn_progress)), + "learn.tab" => tab(app, p), "learn.skills" => skills_list(app), + "learn.skill" => open_skill(app, p), "learn.show" => open(app, p), + "learn.back" => back_up(app), "learn.locateTool" => locate::locate_tool(app, p), "learn.openSample" => open_sample(app, p), "learn.markStep" => mark_step(app, p), @@ -397,23 +412,85 @@ fn toggle(app: &mut VectorcraftApp, p: &Value) -> Result { /// 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(), - "finished": progress.is_finished(t.id), - "completedSteps": progress.completed_steps(t), - }) + json!({ "skills": catalog::SKILLS.iter().map(|s| skill_json(progress, s)).collect::>() }) +} + +/// A skill as `learn.list` lists it: `{id, title, done, total, tutorials: [{id, title, steps, +/// finished, completedSteps}]}`. +fn skill_json(progress: &progress::Progress, s: &catalog::Skill) -> Value { + 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::>() }) + }) + .collect(); + json!({"id": s.id, "title": s.title, "done": progress.done(s), "total": s.tutorials.len(), "tutorials": tutorials}) +} + +/// What the panel shows: `{tab: forYou|skills, skill, tutorial}`, with null for no skill or no +/// tutorial open. +fn view_json(view: &View) -> Value { + let tab = TABS.iter().find(|(t, _)| *t == view.tab).map(|(_, name)| *name); + json!({"tab": tab, "skill": view.skill, "tutorial": view.tutorial}) +} + +/// `learn.tab {tab}`: the For you and Skills tabs. Show the panel on that tab (`forYou` or +/// `skills`); the Skills tab keeps the skill and tutorial it had open → the view ([`view_json`]). A +/// missing or unknown tab is an error and changes nothing. +fn tab(app: &mut VectorcraftApp, p: &Value) -> Result { + let key = "tab"; + let name = match p.get(key) { + None | Some(Value::Null) => return Err(format!("missing `{key}`")), + Some(Value::String(name)) => name.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let names = || TABS.map(|(_, name)| name).join(", "); + let tab = TABS.iter().find(|(_, n)| *n == name).map(|(t, _)| *t).ok_or_else(|| format!("`{key}` must be one of {}", names()))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn.tab = tab; + Ok(view_json(&app.ui.learn)) +} + +/// `learn.skill {skill}`: a skill's row on the Skills tab. Show the panel on the Skills tab with +/// that skill's tutorials → the skill, as `learn.list` lists it. A missing or unknown id is an error +/// and changes nothing. +fn open_skill(app: &mut VectorcraftApp, p: &Value) -> Result { + let key = "skill"; + let id = match p.get(key) { + None | Some(Value::Null) => return Err(format!("missing `{key}`")), + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let ids = || catalog::SKILLS.iter().map(|s| s.id).collect::>().join(", "); + let skill = catalog::skill(id).ok_or_else(|| format!("`{key}` must be one of {}", ids()))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }; + Ok(skill_json(&app.ui.learn_progress, skill)) +} + +/// `learn.back`: the Back button on the Skills tab. Show the panel and go up one level: from a +/// tutorial's steps to its skill's tutorials, and from a skill's tutorials to the list of skills → +/// the view ([`view_json`]). On the list of skills or the For you tab the view stays as it is. +fn back_up(app: &mut VectorcraftApp) -> Result { + crate::panel_docking::reveal_panel(app, ID)?; + let view = &mut app.ui.learn; + if view.tab == Tab::Skills { + if let Some((skill, _)) = view.tutorial.and_then(catalog::tutorial) { + view.skill = Some(skill.id); + view.tutorial = None; + } else if view.skill.is_some() { + view.skill = None; + view.tutorial = None; + } + } + Ok(view_json(view)) } /// `learn.skills`: Back to skills, and the Quick skill guide card. Show the panel on the Skills tab diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index ea65a64fe9..319ca1decf 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -497,6 +497,90 @@ fn learn_show_rejects_a_missing_or_unknown_tutorial() { } } +/// The tab buttons, a skill's row, a tutorial's row and Back run `learn.tab`, `learn.skill`, +/// `learn.show` and `learn.back`: each one also shows a panel that was hidden, as the command does. +#[test] +fn the_tabs_rows_and_back_run_their_commands() { + for cmd in ["learn.tab", "learn.skill", "learn.back"] { + assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == cmd), "{cmd} is in the UI command catalog"); + } + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("gradient").unwrap(); + let press = |app: &mut VectorcraftApp, name: &str, view: View| { + app.run("window.learn", json!({"show": false})).unwrap(); + assert!(!shown(app)); + click(app, &ctx, name, show); + assert_eq!(app.ui.learn, view, "{name}"); + assert!(shown(app), "{name} runs its command: {}", app.ui.status); + }; + press(&mut app, "Skills", View { tab: Tab::Skills, skill: None, tutorial: None }); + press(&mut app, skill.title, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); + press(&mut app, tutorial.title, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }); + press(&mut app, "For you", View { tab: Tab::ForYou, skill: Some(skill.id), tutorial: Some(tutorial.id) }); + press(&mut app, "Skills", View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }); + press(&mut app, "Back", View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); + press(&mut app, "Back", View { tab: Tab::Skills, skill: None, tutorial: None }); +} + +/// A click on the tab the panel already shows runs no command. +#[test] +fn a_click_on_the_shown_tab_runs_no_command() { + let mut app = app(); + let ctx = context(); + for name in ["Skills", "For you"] { + click(&mut app, &ctx, name, show); + let commands = app.run_count; + click(&mut app, &ctx, name, show); + assert_eq!(app.run_count, commands, "{name}"); + } +} + +/// `learn.tab` switches tabs and keeps what the Skills tab had open; `learn.skill` opens a skill's +/// tutorials and answers the skill as `learn.list` lists it; `learn.back` goes up one level and +/// keeps the view on the list of skills and on the For you tab. +#[test] +fn learn_tab_skill_and_back_move_the_view() { + let mut app = app(); + let list = app.run("learn.list", json!({})).unwrap(); + assert_eq!(app.run("learn.skill", json!({"skill": "color"})).unwrap(), list["skills"][2]); + assert!(shown(&app)); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: None }); + app.run("learn.show", json!({"tutorial": "gradient"})).unwrap(); + let view = json!({"tab": "forYou", "skill": "color", "tutorial": "gradient"}); + assert_eq!(app.run("learn.tab", json!({"tab": "forYou"})).unwrap(), view); + assert_eq!(app.run("learn.back", json!({})).unwrap(), view, "Back on the For you tab keeps the view"); + assert_eq!(app.run("learn.tab", json!({"tab": "skills"})).unwrap(), json!({"tab": "skills", "skill": "color", "tutorial": "gradient"})); + assert_eq!(app.run("learn.back", json!({})).unwrap(), json!({"tab": "skills", "skill": "color", "tutorial": null})); + let skills = json!({"tab": "skills", "skill": null, "tutorial": null}); + assert_eq!(app.run("learn.back", json!({})).unwrap(), skills); + assert_eq!(app.run("learn.back", json!({})).unwrap(), skills, "Back on the list of skills keeps the view"); + app.run("window.learn", json!({"show": false})).unwrap(); + assert_eq!(app.run("learn.back", json!({})).unwrap(), skills); + assert!(shown(&app), "learn.back shows the panel"); +} + +/// `learn.tab` and `learn.skill` reject a missing, mistyped or unknown value and change nothing. +#[test] +fn learn_tab_and_learn_skill_reject_a_missing_or_unknown_value() { + let mut app = app(); + let before = app.ui.learn.clone(); + let skills = catalog::SKILLS.iter().map(|s| s.id).collect::>().join(", "); + for (cmd, params, error) in [ + ("learn.tab", json!({}), "missing `tab`".to_string()), + ("learn.tab", json!({"tab": null}), "missing `tab`".to_string()), + ("learn.tab", json!({"tab": 1}), "`tab` must be a string".to_string()), + ("learn.tab", json!({"tab": "Skills"}), "`tab` must be one of forYou, skills".to_string()), + ("learn.skill", json!({}), "missing `skill`".to_string()), + ("learn.skill", json!({"skill": ["draw"]}), "`skill` must be a string".to_string()), + ("learn.skill", json!({"skill": "nope"}), format!("`skill` must be one of {skills}")), + ] { + assert_eq!(app.run(cmd, params.clone()).unwrap_err(), error, "{cmd} {params}"); + assert_eq!(app.ui.learn, before); + assert!(!shown(&app)); + } +} + /// The Pen tutorial's steps, as the panel shows them. fn pen_paths(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.ui.learn = View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("pen-paths") }; diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index f6c9f064dc..f570beaeca 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -1060,7 +1060,9 @@ fn reset_panel_leaves_an_undo_group_open_in_another_document() { // The second document is open before the group begins, so switching to it opens no group in it. app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); assert_eq!(app.session.active_index(), Some(1)); - app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap(); + let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone(); + // A reset that changes nothing records no step, so B starts away from the panel's defaults. + app.run("text.setFormat", json!({"id": b, "mojikumi": "none"})).unwrap(); app.session.set_active(0); app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); app.run("text.setFormat", json!({"id": a, "mojikumi": "none"})).unwrap(); diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 6c7382200f..f45ba93410 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -55,6 +55,29 @@ fn start_lists_from_typed_markers_is_a_saved_type_preference() { } } +/// Type › Show Character Alternates is a checkbox in the Type panel, on by default; turning it +/// off is saved, and every bundled language translates its label and the row's two tooltips. +#[test] +fn show_character_alternates_is_a_saved_type_preference() { + const LABEL: &str = "Show Character Alternates"; + let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + assert!(a.session.prefs.show_character_alternates); + open(&mut a, Some("Type")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains(LABEL), "{text}"); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields.get("showCharacterAlternates"), Some(&json!(true))); + a.ui.dialog.as_mut().unwrap().fields.insert("showCharacterAlternates".into(), json!(false)); + confirm(&mut a).unwrap(); + assert!(!a.session.prefs.show_character_alternates); + let saved: Prefs = serde_json::from_value(a.ui.engine_prefs.clone()).unwrap(); + assert!(!saved.show_character_alternates); + for lang in crate::i18n::Lang::all().filter(|l| l.code() != crate::i18n::Lang::EN.code()) { + for source in [LABEL, "Click to replace", "Show all alternates in the Glyphs panel"] { + assert!(crate::i18n::has(lang, source), "{}: {source}", lang.code()); + } + } +} + /// A resting checkbox must have a visible outline in both states and every brightness. #[test] fn preference_checkboxes_draw_outlines_without_hover() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a5ff38be13..0e0d2ced4e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -282,7 +282,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, the CIDFont is `Ryumin-Light` or `GothicBBB-Medium` (the Japanese CIDFonts a printer carries), or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | DCS 1.0 / 2.0 | place linked | place linked only (one image of the plates; spot plates as spot swatches; the composite's clipping path; each plate on its own ink in Separations Preview and print separations) | no | `crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); a masked group, or a knockout group's masked group, whose content has a shadow, glow, blur or feather starts with a hidden, unpainted rectangle over the mask's region, so readers that draw a masked group only inside the box of its shapes draw those effects too (`crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | diff --git a/docs/gaps.md b/docs/gaps.md index 36dd83cd3c..5e31ddee13 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -634,6 +634,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi made to an object inside it at the object in the same place ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); a group that holds type or an image is not taken, as Global Edit does not take images or text ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)); + rotated copies of the object or group are taken too, and an edit reaches each turned by its angle + ([public source](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html); reflected copies are not + taken, VectorCraft's own rule); with one such object or group selected with the Selection tool, the objects Start Global Edit would take are highlighted on the canvas before it starts (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); dynamic symbols (#4938): Symbol Options › Symbol Type (Dynamic Symbol / Static Symbol; `type` on @@ -679,8 +682,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic - Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons and Weather Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; - Global Edit of rotated copies as matches. + Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons and Weather Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write. - **Estimate:** 4–14 h. ## G3 3D and Materials diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 29e3f23038..20311a5182 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -36,11 +36,11 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 9,166 rows, 3,436 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 178 missing messages for it | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 9,378 rows, 3,563 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 55 missing messages for it, which open #7116, #7211, #7330, #7378 and #7405 translate; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | -| Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | +| Indonesian | `id` | 6,825 rows, 1,036 of them `@msg` and 47 `@msgplural` (counted 2026-11-14 on `main` at `676f0524cc`): the untranslated report lists 60 missing menu strings, 35 `tl!` literals and 13 undo labels for it; not in `COMPLETE_MESSAGES`, and checked against the same scanned messages it lacks 2,508; one plural form | most dialogs and tooltips; status and error messages partial | Latin | no | full | — (review) | | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,563 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 52 missing messages for it, which open #7116, #7211 and #7378 translate) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all (3,438 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 39 missing messages for it) | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | -| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | +| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | dialogs and tooltips all; messages partial (1,750 `@msg` and 49 `@msgplural` rows, counted 2026-11-14: the untranslated report lists 1,875 messages without a Vietnamese row, 53 of which open #7211, #7378, #7405 and #7434 translate) | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | **Historical 12-language score: ~82%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 100 + 100 + 100 + 98 + 100 + 100, over 12). **To `full` for all twelve: ~50–85 h** (historical estimate, not re-scored here). Hindi now has @@ -62,13 +62,12 @@ plus review. Native-speaker review is human work and isn't in the hours. | Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending | | Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending | | Czech | `cs` | 9,273 rows, 3,560 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 2 missing Czech messages, and 54 messages that no language has a row for | catalog complete; render-site gaps reduced; native review pending | -| Bosnian | `bs` | 5,841 rows (counted 2026-10-30): menus, `tl!` literals and messages complete when counted; one/few/other plurals | full; AI-assisted, native review pending | +| Bosnian | `bs` | 6,887 rows, 1,144 of them `@msg` and 47 `@msgplural` (counted 2026-11-14 on `main` at `676f0524cc`): the untranslated report lists 97 missing menu strings, 53 `tl!` literals and 27 undo labels for it; not in `COMPLETE_MESSAGES`, and checked against the same scanned messages it lacks 2,416; one/few/other plurals | full; AI-assisted, native review pending | | Swedish | `sv` | 8,991 rows, 3,106 of them `@msg` and 48 `@msgplural` (counted 2026-11-14): the untranslated report lists 518 missing messages for it, and 12 menu strings, 1 `tl!` literal and 3 undo labels; one/other plurals | full; native review welcome | | Bulgarian | `bg` | 5,245 rows, 763 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial | full; AI-drafted, native review pending | | Greek | `el` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | full; native review welcome | | Georgian | `ka` | 5,200 rows (counted 2026-10-21): menus, panels, dialogs, plurals (one count-neutral form) and messages, tested complete; reviewed by a native speaker | full | | Turkish | `tr` | 5,558 rows, 880 of them `@msg` (counted 2026-10-28); 6 menu strings and the default swatch names still fall back to English | full | -| Indonesian | `id` | 5,531 rows, 788 of them `@msg` (counted 2026-10-28): menus and `tl!` literals complete when counted; its messages are partial; one plural form | full | | Catalan | `ca` | 5,626 rows, 881 of them `@msg` (counted 2026-10-28): menus, `tl!` literals and messages complete when counted; one/other plurals; every `ca-*` locale resolves here | full; AI-drafted, native review welcome | Twenty-seven non-English catalogs in all (registry count, 2026-11-06). Illustrator, as its public documentation lists, ships around two dozen diff --git a/docs/mcp.md b/docs/mcp.md index 6cde083ad9..a31a7d9688 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -667,7 +667,7 @@ sampler. | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | -| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | +| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted or null `index` uses the current state. An `index` that names no state fails with `no history state N`, one that is not a whole number with `` `index` must be a whole number ``, and either changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | For example, create editable Japanese area type with the engine's vertical writing mode: @@ -856,7 +856,8 @@ stored without its placement (its vector, aspect ratio, focal point and freeform own box when the graph is drawn again, so `paint.setGradientGeom` and the `paint.freeform.*` edits do not store the placement they set there. A stroke weight and dashes are stored as the label shows them, also on a label that was scaled. When the characters of labels of one kind change differently, the frontmost label sets what the kind keeps. On -a pie with Legends in Wedges whose legend labels have no fill of their own, the legend labels' fill is stored only when +a pie with Legends in Wedges, a fill a command sets on the legend labels is drawn on every wedge, black included, until +that command is undone; a size, font or stroke change does not set one. Before that, their fill is stored only when every legend label the command reaches ends up with the same fill. Otherwise they are drawn again in black, or white on a dark wedge. These commands do not store the colors they give a graph's axis lines, ticks and radar rings, which are drawn again with their default paint. @@ -1085,7 +1086,15 @@ or `range`), named `<file>-<artboard>.<ext>`, and returns `{path, files}` (a PDF SVG writes one file per artboard of a `range`); `useArtboards: false` exports the bounds of the visible art with what its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same way. `document.exportPdf {useArtboards: false}` writes that one page as well, and ignores `artboard`, `artboards` and -`range`. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it +`range`. A PDF or `.ai` page of the art's bounds is measured without the non-printing layers that the file leaves out +(unless `includeNonPrinting` or `createLayers` keeps them; a `.ai` file keeps layers by default). An HP-GL plot of the +art's bounds is measured without what the plot leaves out: non-printing layers, objects at 0% opacity, placed images, +gradient meshes whose every point is at 0% opacity, and paths without effects whose fills and strokes paint nothing. +An image or a mesh with a Crop Marks effect, or with a Vector Halftone effect that makes dots, is measured. +Such objects in the art of a symbol that is not dynamic are left out of its instances too, and an instance whose +symbol holds nothing else is left out. +A document whose only visible art is left out is refused, and Export As refuses an HP-GL plot of it before the save +dialog. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with `useArtboards: false` writes that one page too, without the native document and without the editing copy for other apps (`editingData`), and always comes back with the warning `Preserve editing was left out: an .ai file of the art's @@ -2771,11 +2780,11 @@ nearest to the pointer. ## Global Edit Select › Start Global Edit is `select.globalEdit {appearance?, size?, artboards?, includeCanvas?}`. With one path, -compound path or symbol instance selected, it finds the objects of the same shape (moved or scaled copies; not rotated -ones), among those `select.all` would take and the objects in their groups, and returns `{active: true, count, ids}`, +compound path or symbol instance selected, it finds the objects of the same shape (moved, scaled or rotated copies; +not reflected ones), among those `select.all` would take and the objects in their groups, and returns `{active: true, count, ids}`, the selected object first (at most 10,000). With a group of such objects selected (nested groups and clipping paths included; not type or images), it finds the groups like it: as many objects, in the same order, each of the same -shape, lying the same way relative to each other (the group moved or scaled as a whole). While Global Edit is off, +shape, lying the same way relative to each other (the group moved, scaled or rotated as a whole). While Global Edit is off, the command is disabled for any other selection, including a group that holds type, an image or nothing (it is off in the Select menu, the Properties panel's Quick Actions do not show it, and the Control bar does not show its Global Edit Options). `appearance` also asks for @@ -2784,7 +2793,9 @@ the same fill and stroke (for groups, of each object in them), `size` for the sa params come from `select.globalEditOptions` (the Global Edit Options, kept for the session; it returns them with `count`, the objects a start would take now). The selection stays the one object. Until the next `select.globalEdit` (Stop Global Edit, `{active: false}`), what an edit of a selected match changed is given to the other matches, in the -same undo step, and nothing else. A changed outline or anchors go to each match in proportion to its size. A changed +same undo step, and nothing else. A changed outline or anchors go to each match in proportion to its size, and to a +rotated copy turned by its angle: an anchor moved to the right on the selected object moves upward on a copy rotated +a quarter turn counter-clockwise. A changed opacity or blending, and each changed setting of a fill or stroke (paint, weight, opacity, blending, effects…), are copied as they are to the match's fill or stroke of the same kind and rank; its other fills and strokes, and their unchanged settings, stay its own (a stroke weight change leaves the fills). A stroke weight set on the selected match @@ -2800,6 +2811,16 @@ nothing. Nothing of it is saved. Before Global Edit starts, while one object tha selected with the Selection tool, the canvas highlights the other objects a start would take with the current Global Edit Options (none while a drag is in progress, or when there are none). +Rotated copies are edited together as in [Design a modern geometric +logo](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html), where copies of a group rotated +around a point are edited at once from one of their objects. These rules for them are VectorCraft's own. A copy's +angle is the one that lays each anchor and handle of the selected object on the copy's anchor and handle of the same +order, so a square whose anchors were turned a quarter turn is a copy rotated a quarter turn. The copy is the same +shape when, laid so, every anchor and handle lies within a millionth of the larger side of the copy's box of its own. A +reflected copy is another shape, and so is a copy stretched along one direction. With `size`, a rotated copy has the +same size when it is the selected object rotated without scaling: a 30 × 70 pt object and its copy rotated a quarter +turn (70 × 30 pt by its box) have the same size. + `select.globalEditExclude {id}` leaves the match `id` out of the Global Edit, as a Shift-click on a highlighted match (or on an object inside it) with the Selection tool does: it is no longer highlighted, and later edits leave it as it is. It returns `{active, count, ids}`, the objects left in the Global Edit; with fewer than two left, the Global Edit @@ -2924,8 +2945,16 @@ tutorials, `steps` the number of a tutorial's steps, `finished` whether it is fi zero-based indexes of its steps marked done (every step of a finished tutorial). `learn.skills {}` shows the panel on its Skills tab with the list of skills, with no skill and no tutorial open, as the Quick skill guide card and Back to skills do, and answers what `learn.list` answers. `learn.show {tutorial}` shows the panel on its Skills tab with that -tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error -and changes nothing. +tutorial's steps → `{skill, tutorial, title, steps: [text]}`; choosing a tutorial's row runs it. A missing, non-string +or unknown `tutorial` is an error and changes nothing. A click on the For you or Skills tab button runs +`learn.tab {tab}` when the panel shows the other tab, and runs no command when the panel already shows that tab. +`learn.tab {tab}`, with `tab` set to `forYou` or `skills`, shows the panel on that tab → `{tab, skill, tutorial}`, +with null for no skill or no tutorial open; the Skills tab keeps the skill and tutorial it had open. Choosing a skill's row runs +`learn.skill {skill}`, which shows the panel on the Skills tab with that skill's tutorials → the skill as `learn.list` +lists it. A missing, non-string or unknown `tab` or `skill` is an error and changes nothing. Back runs `learn.back {}`, +which shows the panel and goes up one level on the Skills tab, from a tutorial's steps to its skill's tutorials and +from a skill's tutorials to the list of skills, and answers what `learn.tab` answers; on the list of skills or the For +you tab the view stays as it is. Under a tutorial's steps, View more opens the tutorial's practice sample, a small drawing of VectorCraft's own with the art its steps work on, as a new untitled document that becomes the active one; Save asks where to save it. Once the @@ -2952,11 +2981,11 @@ finished and every step marked. The desktop app keeps the progress in its prefer when two windows change it, the one that saves last keeps its progress. The web build keeps it while the page is open. Marking steps by hand, keeping the progress and Reset progress are VectorCraft's own rules. `learn.markStep {tutorial, step, complete?}` runs a step's check box → `{tutorial, step, complete}`: `step` is the -zero-based step number, and `complete` marks the step done (`true`, omitted or null) or not done (`false`). A missing -`step` or one the tutorial does not have, a `complete` that is not true or false, a tutorial whose sample was not -opened, and a finished tutorial are errors. `learn.resetProgress {}` runs Reset progress and answers what `learn.list` -answers; open documents, opened samples and the Learn view stay as they are. For both, a missing, non-string or -unknown `tutorial` is an error, and an error changes nothing. +zero-based step number, and `complete` marks the step done (`true`, omitted or null) or not done (`false`). A missing, +non-string or unknown `tutorial`, a missing `step` or one the tutorial does not have, a `complete` that is not true or +false, a tutorial whose sample was not opened, and a finished tutorial are errors, and an error changes nothing. +`learn.resetProgress {}` runs Reset progress and answers what `learn.list` answers; open documents, opened samples and +the Learn view stay as they are. The Home screen has a Learn section between Recent Files and the new-file presets, as [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/homescreen-overview.html) @@ -2973,6 +3002,9 @@ rules. {"name":"invoke_menu","arguments":{"command":"learn.finish","params":{"tutorial":"pen-paths"}}} {"name":"invoke_menu","arguments":{"command":"learn.next","params":{"tutorial":"pen-paths"}}} {"name":"invoke_menu","arguments":{"command":"learn.skills","params":{}}} +{"name":"invoke_menu","arguments":{"command":"learn.skill","params":{"skill":"draw"}}} +{"name":"invoke_menu","arguments":{"command":"learn.back","params":{}}} +{"name":"invoke_menu","arguments":{"command":"learn.tab","params":{"tab":"forYou"}}} {"name":"invoke_menu","arguments":{"command":"learn.resetProgress","params":{}}} ``` @@ -3608,7 +3640,7 @@ already walks Image Trace. Short agent entry points: is changed, renamed or deleted. In the app, `ui.imageTracePresetDialog {params?, rename?}` opens the Image Trace panel's Save as New Preset… or Rename… dialog. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, - `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, + `livePaint.info {group?|id?}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most covering source, including compound paths with shared curved boundaries. Expand keeps those face fills. `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity @@ -3617,6 +3649,13 @@ already walks Image Trace. Short agent entry points: row is hidden is removed. `livePaint.merge` copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. + `livePaint.info` lists each face's top fill as `fill` and each edge's top stroke and its weight as `stroke` and + `width`, and lists every fill and stroke row of each face and edge in `appearance`, bottom first, as + `{index, kind, paint, opacity, visible, width?, drawn}`: `index` counts fills and strokes together, `opacity` runs + from 0 to 1, and `width` (strokes only) is the weight in points. `drawn` is true when the row draws by the rule + above: the part is shown with an opacity above 0, and the row is shown, has paint and has an opacity above 0 (a + stroke also a weight above 0). An unpainted top row can sit over a lower row that draws, so `fill`, `stroke` and + `width` alone may not describe what the part draws. With `detect: true`, paint stops at the gaps that run from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview @@ -5446,6 +5485,13 @@ keeps the segment's direction when the smoothed path there turns back by 90° or path, the curve keeps the direction of the part outside the drag. `object.path.smooth` and Simplify keep each end's direction. +`path.smoothRegion` keeps an anchor as a corner where the path turns by more than 57.5° (an angle inside the corner +under 122.5°) at the anchor and also over `radius` and over twice `radius` on either side of it (on a closed path that the drag reaches all the +way round, no more than an eighth of its length), so a jag or a bump smaller than `radius` is still smoothed. The stretches on either side of a kept corner are smoothed and refitted on their +own, and the corner keeps its place and the directions the path arrives and leaves along, on every call and at any +`fidelity`. A call that changes no anchor returns `{ids: [], before: 0, after: 0}` and adds no undo step. The 57.5° +threshold is VectorCraft's own value. + In the desktop app, Object › Path › Smooth, the canvas context menu's Smooth… (listed while the selection holds a path) and the Contextual Task Bar's Smooth (with a path selected whole with the Direct Selection tool) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at the @@ -6339,6 +6385,10 @@ Pen's second anchor at (150, 431), in line with the square's centre. - `highlightAlternateGlyphs` (off by default): Preferences › Type › Highlight Alternate Glyphs. On, the canvas paints pink behind OpenType substitutions (ligatures, contextual forms). Document Setup's Highlight Substituted Glyphs stays separate. Agents set it with `prefs.set`; the desktop canvas reads it on the next paint. +- `showCharacterAlternates` (on by default): Preferences › Type › Show Character Alternates. On, the desktop canvas + shows the alternate glyphs of the one character selected with a Type tool beside it (see + [Alternate glyphs](#alternate-glyphs)). Off, it shows none. It is VectorCraft's own option. `prefs.set` refuses a + value that is not a boolean. - `highlightSubstitutedFonts` (on by default): Preferences › Type › Highlight Substituted Fonts. On, the canvas paints pink behind text drawn in a substitute for a missing font, in every open document. Off, no document shows that highlight. `document.setup` ignores this key and adds a `warnings` entry that names `prefs.set` to its reply. A file @@ -6763,7 +6813,10 @@ copies in artboard order); `artboard` wins when both are given. Without either, several artboards selected, they paste a copy on each of those, and the journal records them as `artboards`; a recorded action leaves both out. Art copied from no artboard keeps its place on the first artboard named and its offset to that one on the others. An index the document does not have, a repeated index, a value that is not an index -or an empty list fails, and nothing is pasted. Copied artboards come back once, where they were, whichever artboards +or an empty list fails, and nothing is pasted. An `artboard` that is not a whole number, null included, fails with +`` `artboard` must be a whole number ``, and an `artboards` entry that is not one with +`` `artboards` must contain whole numbers ``; both keys are checked when both are given. Copied artboards come back +once, where they were, whichever artboards are named. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`) pastes objects back into the layers and sublayers they came from: in the same document while those are still there, else by the names of the layer and the layers around it (the topmost chain that matches the most names, a shown and @@ -6806,9 +6859,15 @@ also move the pattern tiles of the copies when `patterns` is true (default: pref `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard.move` and `artboard.rearrange` take `lockedAndHidden?` (default: prefs `moveLockedWithArtboard`), and with the Artboard tool chosen `edit.copy` and `edit.cut` pass it on. When art goes along, the journal records the value, so a replay under another preference takes the same art. -Every artboard command that takes an `index` fails with `no such artboard` when the `index` is not the -0-based index of an artboard (past the last one, negative, fractional, a string or null), and the document and the -clipboard stay as they were. +Every artboard command that takes an `index` fails with `no such artboard` when the `index` is a whole number past +the last artboard, with `` `index` must be a whole number `` when it is negative, fractional, a string or another value +that is not a whole number, and with `missing index` when it is null, also in a command that picks an artboard when +`index` is left out. An `indices` (or a replayed `selectedArtboards`) that is not a list fails with +`` `indices` must be a list of artboard indices ``, an empty one with +`` `indices` must be a non-empty list of artboard indices ``, one with an entry that is not a whole number with +`` `indices` must contain whole numbers ``, each naming its key, and one with a whole number past the last artboard +with `no such artboard`. `artboard.align`, `artboard.distribute` and `artboard.distributeSpacing` name `artboard` or +`activeArtboard` the same way. The document and the clipboard stay as they were. These artboard commands refuse a string, a number or another value that is not true or false with `` `art` must be true or false ``, naming the key, and leave the document, its undo history and the clipboard as they were: `moveArt`, `scaleArt`, `strokes`, `corners`, `patterns`, `lockedAndHidden` and an artboard's `locked`, @@ -7898,7 +7957,7 @@ instructions is refused with "not an HP-GL file". `document.export` (and `export`, and Export As) with format `hpgl` writes a plot file (`.plt`) of the first artboard (or `artboard`) for pen plotters and cutters; `useArtboards: true` writes one file per chosen artboard -(`{stem}-{artboard}.plt`), `false` the bounds of the visible art. Each visible path is drawn once along its own line: +(`{stem}-{artboard}.plt`), `false` the bounds of the art it plots. Each visible path is drawn once along its own line: a filled shape is its closed outline, also when a stroke gives its color, and a stroked path is its center line, so fills, stroke widths, dashes, brushes and arrowheads are not painted. Type is drawn as its glyph outlines, and a gradient mesh as its outline, also inside a blend (a mesh whose every point is at 0% opacity draws nothing). A @@ -8388,7 +8447,8 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane text reflows along it. `text.setPathHandle {id, subpath?, anchor, which: "in"|"out", x, y, independent?}` moves a direction handle of one of its anchors, as `path.setHandle` moves a path's (Direct Selection dragging a handle of the path, #3565), in one -undo step. +undo step. A call of `text.reshapeArea`, `text.reshapePath` or `text.setPathHandle` that changes nothing (`dx` and +`dy` both 0, or a handle moved to where it already is) adds no undo step and keeps redo. ```json {"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}} @@ -8453,7 +8513,10 @@ the end of the path), and `fit`, `fitScale` and `dynamicText` (Block is on) for Given for several area types (`ids`, or a selection of several), `text.areaOptions` changes only the options given on each one: each frame keeps its other options (it used to take the first frame's), and `fitMinPercent` alone changes only the frames that already shrink text to fit. Options that change nothing (the vertical alignment already set, say) add -no undo step and keep redo. +no undo step and keep redo. The same holds for `text.setText` when no object changes (the text it already has, in one +style, every paragraph with the first one's attributes and no ruby), `text.setStyle` when no character, paragraph or +graph label style changes, `type.ruby` with the same reading and options again or `remove` where there is no ruby, and +`text.fitHeadline` on a first line that already has the tracking it would set. Text that doesn't fit is drawn up to the last line that does, so a one-line headline in a frame lower than its line draws nothing. The commands that make type or change what it holds say so in their own reply: `text.create`, @@ -9456,6 +9519,15 @@ and a glyph that the character's style does not draw (All Caps, Small Caps or an system). Choosing the glyph a character already draws is not an edit. Character styles do not take `glyphAlternate`: `charStyle.new`, `charStyle.edit` and `charStyle.setAttrs` refuse it. +On the desktop canvas, a Type tool with exactly one character selected shows up to five of that character's alternates +in a row beside it, in the same order and without the glyph the character is drawn with; a character drawn with an +alternate gets its default glyph first. A click on a glyph runs the same command as the panel's double-click +(`text.setRangeStyle {featureChanges}`, or `text.setGlyphAlternate` for a glyph no feature draws there), as one undo +step. When the character has more alternates than the row shows, an arrow at the end of the row opens the Glyphs panel +on Alternates for Current Selection. The row has no command of its own: an agent reads the alternates with +`text.glyphAlternates` and applies one with the commands above. The `showCharacterAlternates` preference turns the row +off. + ```json {"name":"run_command","arguments":{"command":"text.glyphAlternates","params":{"id":7,"start":2}}} {"name":"run_command","arguments":{"command":"text.setGlyphAlternate","params":{"id":7,"start":2,"gid":1043}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d854dbb6b3..96713677e5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -618,6 +618,7 @@ since 2026-10-01. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged. +- **Raster effects before a Transform effect that makes copies:** the Transform effect can make copies with its transformation applied ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)). Giving each copy the raster effects listed before that effect is VectorCraft's own rule. On a path or compound path, each copy now carries the Drop Shadow, glow, blur, Feather or pixel filter listed before the last Transform effect that makes copies, with its sizes scaled by that copy's mean scale and a Drop Shadow's offset turned, reflected and scaled with the copy; a raster effect listed after it paints once around all the copies. Before, one shadow was drawn under all the copies at the object's own distances. The canvas, the object's bounds, export and Expand Appearance draw the same copies, and copies that stop at the effect budget each keep their effects (effects `tests::rasterfollow::each_copy_of_a_transform_effect_carries_the_shadow_listed_before_it`, `tests::rasterfollow::a_shadow_after_a_copying_transform_paints_once_around_the_copies`, `tests::rasterfollow::copies_past_the_budget_each_keep_their_shadow`; render `objectfx::each_copy_of_a_transform_effect_draws_its_own_shadow`; engine `objexpand::each_copys_shadow_expands_as_the_canvas_draws_it`). A Transform effect without copies draws as before. Not yet: the copies of type, images, symbol instances and groups, and a fill's or stroke's own copying Transform effect, still share one set of raster effects, and so does a path with Rasterize, an SVG filter or another effect that is neither a geometry effect nor one of those raster effects before the copying effect. Scores and hours unchanged. - **Raster effects before a Transform effect past the effect budget:** VectorCraft leaves out a Transform effect whose result would pass its geometry budget of 2 million anchors or 8 million lines drawn, and the art stays where it was. The raster effects listed before that Transform effect now stay with the art too: a Drop Shadow keeps its own offset and blur, and the Transform effects after it that fit still move both. Before, the art stayed while the shadow scaled with the Transform effect it did not take. This is VectorCraft's own rule. It applies to a path's own effects and to its fills' and strokes' own, also when the path is in a group whose geometry effects run after its members' own, on the canvas and in its bounds, in export, Expand Appearance, Object › Expand and Outline Stroke, and a hidden shadow keeps its values once shown (effects `tests::rasterfollow::a_transform_past_the_line_budget_moves_the_art_with_its_shadow`, `tests::rasterfollow::only_transforms_that_ran_move_a_preceding_shadow`, `tests::rasterfollow::a_skipped_item_transform_keeps_its_shadow`, `tests::rasterfollow::the_shape_art_of_a_skipped_transform_keeps_its_shadows`, `tests::rasterfollow::a_members_skipped_transform_keeps_its_shadow_under_its_groups_effects`; render `objectfx::a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn`, `fx::renderer::tests::a_path_paints_from_the_transforms_that_moved_it`; engine `objexpand::a_shadow_before_a_skipped_transform_expands_with_its_own_offset`). Not yet: the shadow of type, an image, a symbol instance or a group whose art a Transform effect leaves in place past the budget still follows that effect. Scores and hours unchanged. - **Raster effects inside an object or group with a Transform effect:** effects applied to a group affect everything in it ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). How a Transform effect changes the raster effects inside a group or object is VectorCraft's own rule. A group's Transform effect that moves it once now transforms the raster effects of its members, of their fills and strokes, of nested groups and of the art of the linked opacity masks in it, after the members' own Transform effects. A raster effect that a member's own Transform effect with copies or random holds back keeps its value through that effect and still transforms with the group's. VectorCraft applies the same rule to the raster effects of an object's fills and strokes under the object's Transform effect. Sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, with Scale Strokes & Effects on or off. Before, they kept their values, or with Scale Strokes & Effects on scaled by the mean scale with the offset unturned. A group's or object's Transform effect that makes copies or moves at random leaves them as before. The canvas, the redraw region, export, Expand Appearance, Object › Expand and Outline Stroke use the same values (effects `tests::rasterfollow`, `group::tests::a_compound_shapes_transform_turns_its_fills_shadow`; render `a_transform_scales_the_shadows_of_fills_and_members_with_the_art`; engine `objexpand::a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it`, `objexpand::shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them`, `outlinestroke::a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it`). Scores and hours unchanged. - **A group's geometry effects after its members' own:** effects applied to a group affect everything in it, and effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). Running a group's effects after its members' own is VectorCraft's own rule. The geometry effects of a group or layer (the effects that reshape outlines, such as Distort & Transform, Offset Path and Warp) now reshape what its members draw: the geometry effects of each visible path and compound path in it and of their fills and strokes, and a nested group's own geometry and Pathfinder effects, run first, at any depth. A symbol instance's own geometry effects run after those of its symbol's members in the same way. Before, the group's effects reshaped the members' outlines and the members' own effects ran afterwards: a 100 pt square with its own 10 pt Offset Path in a group with a 50% Transform drew 70 pt wide, and it now draws 60 pt wide. Existing documents with such groups draw differently. The canvas, export, Expand Appearance and the art that Pathfinder operations and compound shapes take from such a group use the same order (effects `group::tests::a_members_own_offset_runs_before_its_groups_scale`, `group::tests::a_members_own_scale_runs_before_its_groups_offset`, `group::tests::a_member_of_a_plain_nested_group_takes_its_own_effects_first`, `group::tests::a_nested_groups_own_effects_run_before_its_groups`, `group::tests::a_nested_groups_pathfinder_runs_before_its_groups_effects`, `group::tests::a_members_fill_effects_run_before_its_groups`, `group::tests::shape_art_of_a_group_applies_its_members_effects_first`, `group::tests::a_symbol_instances_effects_run_after_its_symbols_members_own`, `group::tests::a_baked_group_applies_its_members_effects_first`, `group::tests::a_baked_group_that_keeps_its_type_applies_its_members_effects_first`, `tests::rasterfollow::a_groups_transform_applies_after_its_members_own`; engine `objexpand::a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them`). Not yet: the own geometry effects of images, symbol instances, paths with a linked opacity mask or a 3D effect, and nested groups with a 3D effect in the group still run after the group's. Scores and hours unchanged. @@ -646,6 +647,7 @@ since 2026-10-01. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. - **Start Global Edit is offered only for a group it can start on:** with a group selected, Select › Start Global Edit is disabled, and the Properties panel's Quick Actions and the Control bar do not offer it or its options, when the group holds type, an image or nothing, at any depth; the check walks the group's objects the same way, stopping at the first one Global Edit does not take. This is VectorCraft's own rule. Regression test: `start_global_edit_is_offered_only_for_a_group_of_supported_objects` in `crates/engine/src/tests/globaledit.rs`. +- **Global Edit takes rotated copies:** Select › Start Global Edit on an object or group also takes its rotated copies, with the highlights before the start and the options' Results count, and an edit reaches each copy turned by its angle, as copies of a group rotated around a point are edited at once ([Design a modern geometric logo](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html)). Under VectorCraft's own rules, a copy's angle is the one that lays the selected object's anchors and handles on the copy's in order, each within a millionth of the larger side of the copy's box; a reflected copy is not taken; and with Size, a rotated copy has the same size when it is the object rotated without scaling. Regression tests: `start_global_edit_takes_rotated_copies_*`, `a_rotated_copy_leads_*`, `size_takes_a_rotated_copy_*`, `a_square_whose_anchors_were_turned_*`, `rotated_groups_are_like_the_group_*` and `a_rotated_symbol_instance_*` in `crates/engine/src/tests/globaledit.rs`. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. - **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. @@ -682,6 +684,7 @@ since 2026-10-01. - **OpenType SVG colour fonts:** each glyph's SVG document is read by the SVG importer (registered by the engine) into vector art in the run's colours, then drawn, exported and outlined as COLR glyphs are; PDF real text writes them as text that draws nothing and draws their art where they sit. Measured by `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`. Scores unchanged (estimated). - **Create Outlines keeps the type object's own appearance:** Type › Create Outlines and Object › Expand put the type object's own fills and strokes (the Appearance panel's items around its Characters row) on paths of the whole outline, below or above the glyph paths as that row places them, with one gradient across every glyph, so the outlines draw what the type drew; `object.expand.info` reports those strokes, pattern fills and gradient fills. Outlined characters keep their stroke and fill (the help page, <https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=337>); a type object's appearance is separate from its characters' (the help page, <https://helpx.adobe.com/illustrator/using/appearance-attributes.html>); where the object's items go is VectorCraft's own rule. Measured by `tests::pathops::create_outlines_keeps_the_objects_own_fills_and_strokes`, `tests::objexpand::create_outlines_draws_the_type_objects_own_paint`, `tests::objexpand::type_objects_own_stroke_pattern_and_gradient_are_reported`, `tests::objexpand::type_without_an_outline_reports_no_own_stroke_or_fill`, `tests::objexpand::a_stroke_under_an_unpainted_stroke_is_reported_and_outlined` and `tests::objexpand::expand_keeps_the_type_objects_own_paint`. Scores unchanged (estimated). - **Composite fonts that two documents define differently:** when two open documents each define a composite font (Type › Composite Fonts…) by the same name with different parts, each document draws and exports its type in its own definition: in a tiled or floating window drawn while the front document draws, when it is placed in a document that defines the name differently, and in an export made while another document is in front. Evidence: `tests::composite::nested_loans_of_one_name_use_each_documents_definition` and `threads_lending_one_name_at_once_use_their_own_definitions` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` (`crates/render`). Scores unchanged (estimated). +- **Composite fonts only another document defines:** a document draws, measures and exports its type only in the composite fonts it defines itself. Type naming a composite font that only another open document, or the document a placed document sits in, defines is set as in a missing font, also when the document defines no composite font: in a window, a thumbnail or an export of a document in the back, in a file being opened and in a placed document. A temporary document drawn while a document draws keeps that document's composite fonts. Evidence: `tests::composite::a_lent_document_does_not_use_another_documents_composite_font` and `a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` and `a_document_draws_type_in_another_documents_composite_font_as_missing` (`crates/render`), `a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing` (`crates/engine`). Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). @@ -929,6 +932,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - The German catalog (`crates/ui-egui/src/i18n/de.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,563 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins, including the image export, color profile, color book, swatch exchange file, DXF import and Revolve errors that no other catalog translates yet. The report lists 52 German messages: the composite font and SVG filter errors that open #7211 and #7378 translate, and the justification order error that open #7116 translates. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The Korean catalog (`crates/ui-egui/src/i18n/ko.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,438 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 39 Korean messages; they are the composite font, select-a-path, justification order and `path.freehand` connect errors that open #7211, #7214, #7116 and #7330 translate. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. + - The Vietnamese catalog (`crates/ui-egui/src/i18n/vi.tsv`) has an `@msg` row for 115 of the status and error messages that the untranslated report listed for every complete-message catalog, among them the image export, color profile, color book, swatch exchange file, swatch library, DXF and DWG import, 3D and Revolve errors and the `index` whole-number error, 1,750 `@msg` rows in all. The untranslated report (`untranslated_report`) lists 1,875 Vietnamese messages without a row; open #7211, #7378, #7405 and #7434 translate 53 of them. - The Swedish catalog (`crates/ui-egui/src/i18n/sv.tsv`) translates status and error messages, among them the image export, color profile, color book, swatch exchange file, DXF import, 3D and Revolve errors and the `index` check of `document.activate`, 3,106 `@msg` rows in all. The untranslated report (`untranslated_report`) lists 518 Swedish messages, 54 of them messages that no catalog translates yet. - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. @@ -942,7 +946,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Perspective Distort and Free Distort (`object.distort`) turn each warped object's bounding box as the warp turns the object at its own center, the objects inside groups, compound paths and clip groups and the art of linked opacity masks included, so the next Selection or Free Transform drag uses the tilted box; Object › Transform › Reset Bounding Box squares it again, and corners left where they are add no undo step (VectorCraft's own rule; `bboxrotate::distort_turns_an_upright_box_with_the_warp_at_its_center`, `distort_turns_each_member_and_linked_mask_by_its_own_warp`, `mirrored_and_collapsing_distorts_keep_valid_angles`, `distort_angles_undo_redo_and_reopen_together`, `xform::unchanged_distort_records_no_history`). - The Free Transform widget's Constrain works in Free Distort as well as in Free Transform ([the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/free-transform-tool.html)). With Constrain on, or Shift held, a corner dragged in Free Distort moves only along the line through the opposite corner, in the box's own proportions, and the other three corners stay in place; on a turned box the line runs through the box's own corners. The line is VectorCraft's own rule. Cmd/Ctrl+Alt+Shift on a corner still drags in Perspective Distort, where Constrain stays unavailable (tools `xform::free::tests::free_distort_corner_keeps_to_its_diagonal_with_shift_or_constrain`, `shift_pressed_during_a_free_distort_drag_constrains_it`, `a_turned_box_is_constrained_along_its_own_diagonal`, `along_diagonal_of_a_degenerate_box`; engine `xform::free_transform_constrained_distort_keeps_the_corner_on_its_diagonal`; ui-egui `free_transform::tests::the_widget_sets_the_mode_and_a_corner_drag_then_distorts`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their progress rings and counts of tutorials finished; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). + - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their progress rings and counts of tutorials finished; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list`, `learn.tab {tab}`, `learn.skill {skill}`, `learn.show {tutorial}` and `learn.back` script it, and the tab buttons, the rows and Back run them ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish`, `learn.next` and `learn.skills` (Back to skills) script them. Not done: the help page's short video. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `learn_skills_shows_the_list_of_skills`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`. - A Learn tutorial's steps are marked complete as the learner works, and a skill's progress ring fills as its tutorials are finished, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). Each step has a check box that marks it done once the tutorial's sample is open (`learn.markStep`); Finish tutorial marks every step done; a skill's count shows its tutorials finished of all, and a finished tutorial has a check mark in its skill's list. Marking steps by hand, the count, keeping the progress in the preferences (the web build keeps it while the page is open) and Reset progress (`learn.resetProgress`) are VectorCraft's own rules; `learn.list` reports the progress. Regression tests: `a_step_s_check_box_marks_only_that_step`, `a_step_s_check_box_works_from_the_keyboard`, `finishing_tutorials_fills_their_skill_s_ring_and_count`, `a_skill_with_no_tutorials_shows_0_of_0`, `learn_mark_step_checks_its_params`, `reset_progress_clears_only_the_progress`, `the_preferences_keep_the_progress` and `saved_progress_is_read_leniently` in `crates/ui-egui/src/panels/learn/tests.rs`. @@ -1027,6 +1031,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). + - When only some frames of a threaded story are selected, Remove Line Breaks and Remove Paragraph Breaks go through that story's frames once for all of them, not once per selected frame; each selected frame still loses its own breaks, in the order selected, as one undo step (`addons::cleanup::text::tests::remove_breaks_goes_through_a_partly_named_story_once`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`). - With the Selection tool, a Character or Paragraph panel change to a selected frame of threaded text changes every frame of its story, locked and hidden frames included, as one undo step. The anti-aliasing method, Variable Font axes, the Tabs panel's tab stops and both panels' Reset Panel change the whole story too, and the panels show a value only when every frame of the story has it. A story selected through several of its frames or through a group is changed once, and other type keeps its formatting (VectorCraft's own behavior; `panels::character::tests::selection_tool_changes_reach_every_frame_of_the_story`, `selection_tool_fields_read_every_frame_of_the_story`, `selected_stories_list_each_frame_once`, `panels::paragraph::tests::selection_tool_paragraph_changes_reach_every_frame_of_the_story`, `panels::tabs::tests::selection_tool_tab_stops_reach_every_frame_of_the_story`). @@ -1139,6 +1144,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. `path.setHandle`, `path.moveAnchor`, `text.setPathHandle` (`anchor`), `path.insertAnchor` (`segment`), `artboard.reorder`, `library.moveGroup`, `graphicStyle.move` (`to`), `path.appendAnchor`, `path.close`, `path.reverse` (`subpath`) and the ruler origin commands (`artboard`) refuse a value that is not a whole number of 0 or more, such as `"3"` or `2.5`, with `` `anchor` must be a whole number ``, naming the key, instead of reporting it as missing or reading it as the default subpath or artboard; a null `artboard` is artboard 0 (`tests::params::a_malformed_index_is_not_missing_or_its_default`). + The artboard commands that take `index`, `indices` or a replayed `selectedArtboards`, the artboard Align and Distribute commands (`artboard`, `activeArtboard`), `edit.newDocumentFromState` (`index`) and Paste in Place, in Front and in Back (`artboard`, `artboards`) report a value that is not a whole number as such, naming the key, and keep `no such artboard`, `no history state N` and the paste range messages for a whole number that names nothing; a null artboard `index` is `missing index`, and an `indices` value that is not a list, an empty list and a malformed entry each have their own message (`tests::panelcmds::artboard_lists_name_the_fault_and_the_key`). `path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged. Direct Selection's marquee selects the frame anchors of area type that lie inside it, and a click on a frame anchor selects that one; Shift toggles them. The selected frame anchors show filled. A drag of one of them moves all of them, the arrow keys nudge them, and the type reflows at its size (`text.reshapeArea`, which takes `items` to move the anchors of several frames in one step), as [Create text](http://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) and [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describe. When the selection also holds other objects, path anchors or ruler guides, or when another change to the document or the selection follows the pick, the type is selected whole. A press on a selected object's centre point (Attributes panel › Show Center) selects the object whole, and a drag from it moves the object as one Move, held by that point (#4891; test `the_centre_point_selects_and_moves_the_object_whole`). @@ -1152,7 +1158,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Blend steps of spot gradients with process plates:** when the matching stops of two gradients, or of a gradient and a solid, are tints of one spot swatch, each step's stop stays linked to it at a tint between the two and keeps the process inks printed with it, each of C, M, Y and K between the two stops' amounts; a stop or solid without process inks counts as none of each. Gradients with different stop counts keep the inks at every resampled stop. PDF export writes such a step as one DeviceN shading of the spot and process inks. Tints of one spot color blend into tints of it, as [the help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; the process-ink interpolation is VectorCraft's own rule (`crates/doc/src/blend.rs`, `blend_gradient_spot_process_plates_interpolate`, `resampled_gradient_spot_process_plates_interpolate`, `blend_gradient_missing_plates_mean_zero`, `blend_solid_to_spot_gradient_does_not_copy_plates`; `crates/pdf/src/tests/output.rs`, `blend_steps_between_spot_gradients_with_process_plates_print_both_inks`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, or recolored, given Swap Fill and Stroke or Default Fill and Stroke, or given stroke options or a gradient, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, or recolored, given Swap Fill and Stroke or Default Fill and Stroke, or given stroke options or a gradient, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. On a pie with Legends in Wedges, the legend labels are drawn black, or white on a dark wedge, until a command sets their fill, which is then drawn on every wedge, black included, and kept through save and open. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. @@ -1191,6 +1197,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a Smooth tool drag reaches the first anchor of a closed path, the smoothed path starts at its point nearest where that anchor was: at an anchor there, or at a new anchor split into the curve without changing its outline. Dashes and type on the path start from that point. A drag that does not reach the first anchor leaves it first. This is VectorCraft's own rule (engine tests `smoothing_over_a_closed_paths_first_anchor_keeps_its_start` and `smoothing_a_whole_closed_path_with_a_spike_keeps_its_start`). The Smooth tool shows the Not Allowed cursor while no path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths or two anchors joined by a curve), and the release of a drag then sends no command and changes nothing; [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) has the path selected first. A selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool; the Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. Use Precise Cursors leaves Not Allowed as it is. This cursor rule is VectorCraft's own (tools `smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `smoothing_release_sends_nothing_without_a_selected_path_to_smooth`; engine `use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer`). When a Smooth tool drag reaches the first or last anchor of an open path, that anchor stays where it is and the curve there leaves or arrives along the smoothed path within the reach, so a jagged first or last segment no longer sets the end's direction; an end where the smoothed path turns back by 90° or more keeps its segment's direction, and where the refitted stretch ends inside the path the curve keeps the direction of the part outside the drag. This rule is VectorCraft's own (`tests/draw2.rs`, `smoothing_an_open_path_turns_its_jagged_ends_with_the_path` and `a_smooth_drag_turns_only_the_end_it_reaches`). + A Smooth tool drag keeps a corner where the path turns by more than 57.5° (an angle inside the corner under 122.5°) and rounds a wider one. An anchor is kept as a corner when the path turns by that much at the anchor and also over the drag's reach and over twice the reach on either side of it, so a jag or a bump smaller than the reach is still smoothed; [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes the tool as reducing irregularities in a path. The stretches on either side of a kept corner are smoothed and refitted on their own, and the corner keeps its place and the directions the path arrives and leaves along, on every drag, on open and closed paths, the anchor a closed path starts at included. A drag that changes no anchor adds no undo step. The threshold and the two distances are VectorCraft's own values, the same on every drag and at any Fidelity (`tests/draw2.rs`, `the_smooth_tool_keeps_corners_sharper_than_its_threshold`, `smoothing_a_closed_path_keeps_its_corners` and `a_smooth_drag_that_only_meets_corners_adds_no_undo_step`). A Smooth drag along a curved segment smooths the stretch of it in the drag's reach even when neither of the segment's anchors is in reach, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes dragging along the path segment; the segment is cut where the drag enters and leaves the reach, and the parts outside keep their shape (`path.smoothRegion`). Paths of two anchors are smoothed too, and Alt with the Paintbrush smooths a selected two-anchor curve; a two-anchor straight line still gets the closed stroke. How the reach is found on a segment (samples no more than half the reach apart) is VectorCraft's own, and a straight segment is left as it is. Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). Keep selected in the Pencil and Paintbrush Tool Options is on by default: while it is on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`path.freehand` `keepSelected`). @@ -1207,7 +1214,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle in degrees as its value: a click or a drag in it points the Angle at the pointer and gives it the keyboard focus, Tab reaches it after the Roundness field, and the arrow keys step the Angle by 1° (10° with Shift, Home and End to −180° and 180°). The Blob Brush Tool Options also have Fidelity, which the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list as setting how far the pointer moves before the path gets a new anchor point. The range and default are VectorCraft's own values: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`). A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`). -- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. A guide's row has its own eye and lock, which hide or lock that guide alone in one undo step and take part in a drag down either column ([help page](https://helpx.adobe.com/in_hi/tutorial-builder/user-guide/illustrator-tutorial-builder/-ai--examples-of-common-coach-marks.html) lists a Guide among the Layers panel's items); a hidden guide is not drawn and does not snap, a locked one is drawn and snaps but can't be selected, moved or deleted, and its own state is kept through save and reopen and while its layer is hidden or locked (`layer.setProps` on its `guide.list` id). Object › Lock › Selection and Object › Hide › Selection lock or hide a selected guide with the selected art, and Unlock All and Show All unlock or show such guides and select them ([help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) for Lock and Hide Selection). A guide row dropped on the panel's trash deletes its guide, the trash deletes a guide its row selected, and after a click on a guide row Delete deletes the guide rather than the rows highlighted before ([help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) for deleting a selected guide). Not done: guides stacked among the art of their layer (they are listed above it). Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. +- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. A guide's row has its own eye and lock, which hide or lock that guide alone in one undo step and take part in a drag down either column ([help page](https://helpx.adobe.com/in_hi/tutorial-builder/user-guide/illustrator-tutorial-builder/-ai--examples-of-common-coach-marks.html) lists a Guide among the Layers panel's items); a hidden guide is not drawn and does not snap, a locked one is drawn and snaps but can't be selected, moved or deleted, and its own state is kept through save and reopen and while its layer is hidden or locked (`layer.setProps` on its `guide.list` id). Object › Lock › Selection and Object › Hide › Selection lock or hide a selected guide with the selected art, and Unlock All and Show All unlock or show such guides and select them ([help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) for Lock and Hide Selection). A guide row dropped on the panel's trash deletes its guide, the trash deletes a guide its row selected, and after a click on a guide row Delete deletes the guide rather than the rows highlighted before ([help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) for deleting a selected guide). A plain click on another row lets go of a guide its row selected, keeping the selected art, as a plain click on a row picks that row alone; a Ctrl/Cmd or Shift click keeps it (#7340, `panels::layers` test `a_plain_row_click_lets_go_of_the_selected_guide`). Not done: guides stacked among the art of their layer (they are listed above it). Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. - **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (layers dropped in the empty space under the last row go below the bottom layer, #2260; dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Collect for Export (As Single Asset, As Multiple Assets), Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Right-clicking a layer, sublayer, group or object row offers Collect for Export for that row, or for every highlighted row when it is one of them; guide and blend spine rows have no context menu. Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). With Paste Remembers Layers on, art copied from a sublayer pastes back into that sublayer, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=223) describes art going back to the layer it was copied from: in the same document while the sublayer is still there, two sublayers of the same name in different layers kept apart; in another document by the names of the sublayer and the layers around it. These rules are VectorCraft's own: a missing sublayer is made inside its layer (a missing layer on top, with the sublayer inside it), in the paste's one undo step; of layers with the same names, the topmost chain that matches the most names wins, a shown and unlocked one first; a matched layer that is hidden or locked, or inside one that is, refuses the paste, and nothing is pasted. @@ -1360,6 +1367,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes. The grid lists the selected character's alternate glyphs from `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. A double-click applies the feature that draws the variant, or a glyph choice if no supported feature draws it, as detailed below. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Native files keep a glyph choice with the face it was made in; another font or version draws the default glyph. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`. + With a Type tool and exactly one character selected, the canvas shows that character's alternate glyphs in a row beside it, and a click on one replaces the character's glyph, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes ("Replace a character with on-canvas alternate glyphs"). The row shows at most five alternates, and an arrow at its end opens the Glyphs panel on Alternates for Current Selection when the character has more, as the [reference manual](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=479) describes. A click runs the command the panel's double-click runs, as one undo step, so a letter of an Arabic word keeps the word joined. These rules are VectorCraft's own: the row leaves out the glyph the character is drawn with and puts the default glyph first for a character drawn with an alternate; it stands under a character of horizontal type and on the right of a character of vertical type, or on the other side when the canvas ends there or the Contextual Task Bar is there; Preferences › Type › Show Character Alternates (`showCharacterAlternates`, on by default) turns it off. Tests: `canvas::alternates_bar::tests`, `tests::prefs::show_character_alternates_preference_is_on_by_default_and_saved`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. @@ -1504,6 +1512,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients, live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts}`. + `livePaint.info` lists every fill and stroke row of each face and edge, bottom first, with its paint, opacity, visibility, weight for a stroke, and whether it draws by the rule above, beside the top fill, stroke and weight it already reported. Evidence: `tests::build::live_paint_info_lists_every_appearance_row`. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). @@ -1714,6 +1723,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps each wrap object's Text Wrap Options in its editing copy: the Offset, positive or negative, and Invert Wrap ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/wrap-or-unwrap-text-around-objects.html)), in the form the `.ai` reader reads them (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), so they come back when the file is opened. An offset past the -1000 to 1000 pt the reader keeps is written as the nearer end of that range. On a large canvas the offset is written at the art's scale and comes back at its size. Art the writer draws for an object, such as a blend's steps or a symbol instance written as its art, has only that object's options, and a clipping path written as its fills and its strokes has its options once (`crates/eps/src/ai_editing/tests.rs` `text_wrap_options_are_written_and_read_back`, `drawn_art_keeps_only_its_objects_text_wrap`, `text_wrap_offsets_on_a_large_canvas_come_back_at_their_size`, `text_wrap_options_of_type_and_envelopes_are_read_back`, `a_clipping_path_written_as_its_paint_keeps_one_text_wrap`). + Type written as its outlines in the editing copy still wraps area type by its bounds, not by its glyphs, when the file is opened: the outlines' group ends with an unpainted rectangle at the type's bounds that carries its Text Wrap Options, in the form the `.ai` reader reads a path's options (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), and the group keeps the type's name (`crates/eps/src/ai_editing/tests.rs` `type_written_as_outlines_keeps_wrapping_by_its_bounds`). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). @@ -1757,6 +1767,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`). `document.exportSelection` run without params (a shortcut) takes the same route after its save panel: a picked PDF opens the Save PDF dialog without its Artboards section, and SVG, the raster formats, EPS, DXF, text and exporter plug-ins with parameters open their options, whose OK writes the selection alone, cropped to it, with the chosen options (`crates/ui-egui/src/dialogs/tests_export_selection.rs`). A `.ai` export with Use Artboards off writes one page of the visible art's bounds without the native document or the editing copy for other apps, with a warning, so the file does not reopen with one artboard in place of the document's; without PDF content it is refused (`crates/engine/src/cmd/fileio/tests_pdfedit.rs`). + That page, and an HP-GL plot of the art's bounds, are measured on the art the file draws: non-printing layers the PDF or `.ai` file leaves out do not make it larger, and nor do the non-printing layers, objects at 0% opacity, placed images, clear gradient meshes and unpainted paths a plot leaves out; Export As refuses an HP-GL plot of nothing it draws before the save dialog (`crates/engine/src/cmd/fileio/tests_pdf.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`, `crates/ui-egui/src/dialogs/tests_export.rs`). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. In a PDF without a standard, the warning for a page longer than 14,400 pt written with a `UserUnit` names "Write pages longer than 200 in at full size in points" (`crates/engine/src/cmd/fileio/tests_pdf.rs`). A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`). @@ -1858,6 +1869,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). - A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes the objects of its editing data, and one saved as PostScript pastes as a copied `.eps` file does. + - Paste checks the size of a file copied in a file manager before reading it: a file of more than 64 MiB, VectorCraft's own limit, is skipped and the next copied file that is art pastes. A file that grows past the limit while it is read is skipped too, after at most one byte more than the limit is read. - A copied `.ai` or `.ait` file that holds a PDF pastes the objects of its editing data as File › Open reads them: every layer's objects go into the active layer in their stacking order, with their names, hidden objects and type objects, instead of the PDF page's art with its type as outlines. When the editing data can't be read, the file pastes its page as before (`clipboard.importPdf {editingData}`). The objects of a hidden layer come in hidden, from a pasted EPS too. - Import and export, later 2026-10-13 merges: SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); the warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); the pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); the ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); a zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). - Files and untrusted input, 2026-10-14 merges: EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). @@ -1997,6 +2009,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Objects on Path measures its path once:** placing the objects, finding one object's place and transforming the group flatten each path once and use it both for the path's length and for the points along it, where they flattened it two or three times before; a request for an object past the last one returns nothing without flattening the path, and a move of the group flattens no path. A path whose length adds up past the largest finite number no longer counts as one objects can be attached to. Evidence: `objects_on_path::tests::{stops_and_stop_flatten_the_path_once, transforming_the_group_flattens_each_path_once, placing_the_objects_flattens_the_path_once, a_path_too_long_to_measure_is_not_attachable}` in `crates/doc`. +- **Objects on Path widgets measure the path once:** with one Objects on Path group selected, finding where its Rotate All, Space, Move All and Select/Move widgets go, testing whether the pointer is over one and starting a drag flatten the group's path once, and the canvas draws all four widgets from that one flattening. Before, each widget flattened the path two or three times, and one pointer move over the group could flatten it eleven times. A drag of the Space, Move All or Select/Move widget keeps the measurement it started with and measures again only when the path changes, where it flattened the path twice on every pointer move. The widgets' places, what the pointer hits, the cursors and what each drag changes stay the same. Evidence: `objects_on_path::tests::{each_widget_lookup_flattens_the_path_once, the_widgets_of_a_group_share_one_flattening, a_widget_drag_flattens_the_path_once, a_widget_drag_follows_a_path_that_changed}` in `crates/tools`. + - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index db0ada2fdc..5f26e4377e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -252,7 +252,7 @@ tools, never checked side by side) isn't captured row by row. | A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` | | Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | | Ruler origin: corner drag with preview, snapped as a drawn point is (Smart Guides, Snap to Point and the ruler guides, pixels, the grid), double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | -| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer; each row's own eye and lock; a row dropped on the trash deletes its guide | done | `guide.setLayer`; guides from older files go on a layer; `layer.setProps` on a guide's id hides or locks it alone; Object › Lock/Hide › Selection, Unlock All and Show All reach guides; `guide.remove {indexes}` | +| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer; each row's own eye and lock; a row dropped on the trash deletes its guide; a plain click on another row lets go of the guide | done | `guide.setLayer`; guides from older files go on a layer; `layer.setProps` on a guide's id hides or locks it alone; Object › Lock/Hide › Selection, Unlock All and Show All reach guides; `guide.remove {indexes}`; #7340 | | Objects made into guides: drawn in the guide colour and style of Preferences; Lock Guides locks them too (no select, move or delete); a dragged selection snaps to ruler guides with Smart Guides on, by the nearest point of the path when pressed on its stroke or beside its edge, so the path lands exactly on the guide | done | #1534 (#1543, #1583); #3701 | | A selected ruler guide's X or Y in the Transform panel; typing a value moves it | not scored | #1659 (part of #1597); several guides move together; not in Properties or the Control bar. Not established by the documentation we could find: the help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) don't describe it, and the one public statement is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. Kept as a convenience pending the owner's decision; no parity claim | | Alt swaps a ruler guide's orientation while dragging | missing | Alt-drag copies the selected guides ([Use rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html)); `guide.move` `{copy: true}` | @@ -385,7 +385,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | -| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with progress rings that fill and counts of tutorials finished, a tutorial shows its numbered steps with a check box each that marks the step done once its sample is open, a finished tutorial has a check mark, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished with all its steps, and Next Up shows the next tutorial in the same skill; Reset progress clears the progress (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.skills`, `learn.show`, `learn.locateTool`, `learn.openSample`, `learn.markStep`, `learn.finish`, `learn.next`, `learn.resetProgress`; the mark's look and 3-second lifetime, marking steps by hand, keeping the progress in the preferences and Reset progress are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with progress rings that fill and counts of tutorials finished, a tutorial shows its numbered steps with a check box each that marks the step done once its sample is open, a finished tutorial has a check mark, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished with all its steps, and Next Up shows the next tutorial in the same skill; Reset progress clears the progress (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.tab`, `learn.skills`, `learn.skill`, `learn.show`, `learn.back`, `learn.locateTool`, `learn.openSample`, `learn.markStep`, `learn.finish`, `learn.next`, `learn.resetProgress`; the mark's look and 3-second lifetime, marking steps by hand, keeping the progress in the preferences and Reset progress are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | @@ -419,7 +419,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Actions panel: Delete Selection (bottom bar) deletes the selected set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.actionSet.delete`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: deleting a set asks first (a selected action is still deleted at once), Shift-click on a set's name selects a range of sets, ≡ › Delete does the same as the button, and Default Actions and the last set can be deleted. Undo does not restore a deleted set, and recorded commands cannot be selected or deleted | | Actions panel: ≡ › Duplicate copies the selected action or set ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.actionSet.duplicate`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected, and a copied set holds copies of all its actions. Undo does not remove the copy, and recorded commands cannot be duplicated | | Properties panel per context (every selection type's Quick Actions) | partial | one clip group: Edit Clipping Path and Edit Contents, at the top of the panel rather than in Quick Actions (G1); the overview's additional controls ([Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html)): Blend Options (one blend selected, with any tool, #4817), Repeat Options (one radial, grid or mirror repeat selected: Object › Repeat › Options, #5181; the overview does not list it) and Merge Live Paint (a Live Paint group with more) in Quick Actions, the Width Profile list (Width tool or a variable-width stroke) and an applied effect's Delete icon in Appearance, Corner and Corner Radius for corners selected with Direct Selection; Variable Font was already there; Edit Artboards: a Transform section (reference point, X, Y, W, H, preset, orientation) above the Artboards section (#5219) | -| Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); a group holding type or an image is not taken, as Global Edit does not take images or text; Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; the matches are highlighted before the start (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); rotated copies remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | +| Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); a group holding type or an image is not taken, as Global Edit does not take images or text; Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; the matches are highlighted before the start (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); rotated copies are matched and take an edit turned by their angle ([public source](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html); `start_global_edit_takes_rotated_copies_and_an_edit_reaches_each_turned_by_its_angle`); reflected copies are not matched (VectorCraft's own rule) | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | | Stroke panel: custom arrowheads after the built-in ones in both arrowhead menus, from the arrowheads file that the panel menu's Edit Arrowheads… opens; saving the file reloads the menus, and a stroke keeps a copy of its custom head's art | done | [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) (Customize arrowheads); the file's layout (one symbol per head, pointing right, tip at the registration point, height drawn as wide as the built-in heads) is VectorCraft's own; `stroke.arrowheads.edit`, `stroke.arrowheads.list`, `stroke.arrowheads.reload`, `stroke.set` `{custom: name}`; engine `tests::arrowheads`, `stroke::tests_arrows::custom_heads_draw_their_own_art_at_the_tip` |