From 8ce63aa1d162ed5a095cd44ad64ed76c849cb095 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 18:42:16 +0700 Subject: [PATCH 01/13] .ai import: a gradient with stops in the middle runs its whole ramp (follow-up to #1264) The editing data places a gradient with matrices after Bg: Xm for a linear gradient's whole ramp and one Bm for each pair of stops next to each other. The reader kept the last Bm, so a gradient of three stops drew only its last part. It now uses Xm (or, without one, the span of the Bm's from the first stop to the last), and a radial gradient's one Bm as before. The angle and length are in the matrices, so Bg's own numbers are used only without them. An object's own note of its gradient after it (Bb, Bg, BB with no path) no longer becomes the fill of the next path. --- crates/engine/tests/import_fuzz.rs | 1 + crates/eps/src/import/ai/paint.rs | 97 +++++++++++++++++++++++++----- crates/eps/src/import/ai/read.rs | 12 +++- crates/eps/src/import/ai/tests.rs | 17 ++++++ 4 files changed, 109 insertions(+), 18 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d36fce90..ef80359e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1741,6 +1741,7 @@ fn editing_text() -> String { "U".into(), "0 Xw".into(), "1 0 0 0 1 0 Bg".into(), + "Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n1 0 0 1 0 0 Xm\n0 0 0 0 0 0 Bm\n1 0 0 1 5 5 Bm\n0 BB".into(), "0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(), "0 0 0 1 (Black global) 0.4 0 Xk".into(), "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(), diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 783e7e44..c0f25726 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -9,8 +9,12 @@ //! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6] //! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`. //! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d -//! tx ty Bg`, the gradient matrix `a b c d tx ty Bm`, the focal point `x y angle length Bh`, -//! `flag BB`. +//! tx ty Bg`, the focal point `x y angle length Bh`, `flag BB`. Newer files write the gradient's +//! place as matrices after `Bg` (whose own numbers are then not in art space): `a b c d tx ty Xm` +//! takes the unit vector onto a linear gradient's whole ramp (0 to 100, its angle and length in +//! it), and `a b c d tx ty Bm` takes it onto the part between two stops next to each other, one +//! `Bm` for each pair in the order of the ramp (`Bc`, around them, are the caps past the first and +//! last stops). A radial gradient has one `Bm`, which takes the unit circle onto it. use std::collections::HashMap; @@ -166,8 +170,10 @@ pub(super) struct Instance { length: f64, /// `Bg`'s own matrix. bg: Affine, - /// The gradient matrix (`Bm`), when there is one. - bm: Option, + /// The matrix of a linear gradient's whole ramp (`Xm`), when there is one. + xm: Option, + /// The first and the last gradient matrix (`Bm`), when there are any. + bm: Option<(Affine, Affine)>, /// The focal point's offset from the origin (`Bh`). hilight: Vec2, } @@ -197,10 +203,22 @@ impl Instance { } } - /// `a b c d tx ty Bm`. - pub fn bm(&mut self, vals: &[V]) { + /// The matrix of `a b c d tx ty Bm` or `Xm`, when it can be inverted. + fn matrix(vals: &[V]) -> Option { let n: Vec = vals.iter().filter_map(V::num).collect(); - self.bm = n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12); + n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12) + } + + /// `a b c d tx ty Bm`: one for each pair of stops next to each other. + pub fn bm(&mut self, vals: &[V]) { + if let Some(m) = Self::matrix(vals) { + self.bm = Some((self.bm.map_or(m, |(first, _)| first), m)); + } + } + + /// `a b c d tx ty Xm`. + pub fn xm(&mut self, vals: &[V]) { + self.xm = Self::matrix(vals); } /// `x y angle length Bh`. @@ -215,19 +233,35 @@ impl Instance { pub fn paint(&self, defs: &HashMap, to_doc: Affine) -> Option { let name = self.name.as_ref()?; let gradient = defs.get(name)?.clone(); - let m = to_doc * self.bm.unwrap_or(Affine::IDENTITY) * self.bg; - let a = self.angle.to_radians(); - let dir = Vec2::new(a.cos(), a.sin()) * self.length; - let start = m * self.origin; - let end = m * (self.origin + dir); + let radial = gradient.kind == GradientKind::Radial; + // The unit vector through `m`, the point across it (for an elliptical radial gradient) and + // the focal point. + let unit = |m: Affine| (m * Point::ORIGIN, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0), m * self.hilight.to_point()); + let whole = if radial { self.bm.map(|(m, _)| m).or(self.xm) } else { self.xm }; + let (start, end, across, focal) = match (whole, self.bm) { + (Some(m), _) => unit(to_doc * m), + // A linear gradient's `Bm`s alone: they run from its first stop to its last. + (None, Some((first, last))) => { + let (a, b) = (to_doc * first * Point::ORIGIN, to_doc * last * Point::new(1.0, 0.0)); + let from = gradient.stops.first().map_or(0.0, |s| f64::from(s.offset)); + let to = gradient.stops.last().map_or(1.0, |s| f64::from(s.offset)); + let whole = (b - a) / (to - from).max(1e-6); + let start = a - whole * from; + (start, start + whole, start, start) + } + // `Bg`'s own numbers, in art space through its matrix. + (None, None) => { + let m = to_doc * self.bg; + let a = self.angle.to_radians(); + let dir = Vec2::new(a.cos(), a.sin()) * self.length; + (m * self.origin, m * (self.origin + dir), m * (self.origin + Vec2::new(-dir.y, dir.x)), m * (self.origin + self.hilight)) + } + }; if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 { return None; } - // Across the vector, for an elliptical radial gradient. - let across = m * (self.origin + Vec2::new(-dir.y, dir.x)); let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0); - let focal = (self.hilight.hypot() > 1e-9).then(|| m * (self.origin + self.hilight)).filter(|p| p.is_finite()); - let radial = gradient.kind == GradientKind::Radial; + let focal = (self.hilight.hypot() > 1e-9).then_some(focal).filter(|p| p.is_finite()); let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } }; let angle = -(end - start).atan2().to_degrees(); Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) }))) @@ -308,4 +342,35 @@ mod tests { assert!((geom.aspect - 1.0).abs() < 1e-9); assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none()); } + + /// A linear gradient of three stops as newer files place it: `Bg` with numbers that aren't in + /// art space, `Xm` for the whole ramp and a `Bm` for each pair of stops. + #[test] + fn a_linear_gradient_runs_its_whole_ramp_not_its_last_part() { + let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)]).unwrap(); + for ramp in [100.0, 60.0, 20.0] { + d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![]); + } + let defs = HashMap::from([d.finish().unwrap()]); + let instance = |xm: bool| { + let mut i = Instance::begin(&[]); + let mut bg = vec![V::Num(1.0), V::Str(b"L".to_vec())]; + bg.extend(nums(&[0.3, -0.2, -90.0, 0.5, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0])); + i.bg(&bg); + if xm { + i.xm(&nums(&[0.0, -200.0, -30.0, 0.0, 10.0, 300.0])); + } + // From the stop at 20 to the one at 60, then on to the one at 100. + i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 260.0])); + i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 180.0])); + let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; + gp.geom.unwrap() + }; + for xm in [true, false] { + let geom = instance(xm); + // (The stops' offsets are `f32`.) + assert!(geom.start.distance(Point::new(10.0, 300.0)) < 1e-5 && geom.end.distance(Point::new(10.0, 100.0)) < 1e-5, "{xm}: {geom:?}"); + assert_eq!((geom.aspect, geom.focal), (1.0, None)); + } + } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index e82a534d..83362f48 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -338,7 +338,6 @@ const IGNORED: &[&str] = &[ "X=", "X+", "Bc", - "Xm", "XP", "Np", // Flatness (`i`): how finely curves print. @@ -921,13 +920,22 @@ impl<'a> Reader<'a> { i.bm(&vals); } } + "Xm" => { + if let Some(i) = self.instance.as_mut() { + i.xm(&vals); + } + } "Bh" => { if let Some(i) = self.instance.as_mut() { i.bh(&vals); } } "BB" => { - if let Some(i) = self.instance.take() { + // An instance that closes with no path to paint is an object's own note of its + // gradient, written after it: it paints nothing (not the next path either). + if let Some(i) = self.instance.take() + && !self.path.elements().is_empty() + { self.set_instance(&i); } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 3a468d8c..596aee25 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -281,6 +281,23 @@ fn gradients() { assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0))); // The gradient was the one object's. assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + // A linear gradient with a stop in the middle: `Xm` places its whole ramp, a `Bm` each of its + // two parts. The object's own note of its gradient after it paints nothing. + let defs = "(L) 0 3 Bd\n%_1 0 50 100 Bs\n%_0.5 0 50 40 Bs\n%_0 0 50 0 Bs\nBD\n"; + let art_ = format!( + "{RED}{}Bb\n1 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 -100 -100 0 150 100 Xm\n0 -9000 -100 0 150 9100 Bc\n0 -40 -100 0 150 100 Bm\n\ + 0 -60 -100 0 150 60 Bm\n0 -9000 -100 0 150 0 Bc\nf\n0 BB\n0 1 Xd\nBb\n2 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 BB\n6 () XW\n{}f\n", + rect_path(50.0, 0.0, 150.0, 100.0), + rect_path(0.0, 0.0, 10.0, 10.0), + ); + let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc; + let a = art(&doc); + let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) }; + assert_eq!(g.gradient.stops.len(), 3); + // Art space has y up from the crop marks' bottom. + let geom = g.geom.unwrap(); + assert!(geom.start.distance(Point::new(150.0, 0.0)) < 1e-9 && geom.end.distance(Point::new(150.0, 100.0)) < 1e-9, "{geom:?}"); + assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "the note after the object isn't the next path's paint"); } #[test] From 8e16c8529f6c3331187a082581254388ce813537 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 18:42:18 +0700 Subject: [PATCH 02/13] .ai import: type of text objects with an appearance, on hidden layers and on later artboards goes where it belongs (follow-up to #1264) - The type in a text object's drawn look (1 /FreeUndo) is a story of the undo-free text document, which counts its stories on its own. It was read as the story of that number in the text document, so a label could show another object's text in another object's frame. The object itself, which follows the look, is the type now. - The page's type of a text object on a hidden layer stays with that object (hidden) instead of going to the nearest text object that shows. - The type on the page of a later artboard is matched where the layers have that artboard, not by the first page's place. --- crates/engine/tests/import_fuzz.rs | 1 + crates/eps/src/import/ai/frames.rs | 10 ++++++ crates/eps/src/import/ai/read.rs | 17 ++++++++++ crates/eps/src/import/ai/tests.rs | 14 +++++++++ crates/eps/src/import/native.rs | 45 +++++++++++++++++++++++++-- crates/eps/src/import/tests_native.rs | 45 +++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 3 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d36fce90..e8134599 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1738,6 +1738,7 @@ fn editing_text() -> String { "1 Xw".into(), "u".into(), "/AI11Text :\n0 /FreeUndo ,\n;".into(), + "/AI11Text :\n1 /FreeUndo ,\n;\n1 (Style) XW".into(), "U".into(), "0 Xw".into(), "1 0 0 0 1 0 Bg".into(), diff --git a/crates/eps/src/import/ai/frames.rs b/crates/eps/src/import/ai/frames.rs index 6667c981..24f0b8b8 100644 --- a/crates/eps/src/import/ai/frames.rs +++ b/crates/eps/src/import/ai/frames.rs @@ -40,6 +40,10 @@ pub(crate) fn text_frames(data: &[u8]) -> HashMap<(u32, usize), BezPath> { continue; } let Some(head) = seg.get(..art) else { continue }; + // The type in a drawn look is a story of another text document (see `Reader::text_slot`). + if number_before(head, b"/FreeUndo") == Some(1.0) { + continue; + } let (Some(story), frame) = (number_before(head, b"/StoryIndex"), number_before(head, b"/FrameIndex").unwrap_or(0.0)) else { continue }; if !(0.0..f64::from(u32::MAX)).contains(&story) || !(0.0..1e6).contains(&frame) { continue; @@ -123,6 +127,12 @@ mod tests { assert_eq!(f.len(), 2, "point type has no frame: {f:?}"); assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(10.0, 20.0, 110.0, 80.0)); assert_eq!(f[&(60, 1)].elements().len(), 2, "a curve, led by %_"); + // The type in a drawn look counts its stories in another text document: its frame isn't + // that of the text object which has the same number in this one. + let look = String::from_utf8_lossy(doc).replacen("0 /FreeUndo", "1 /FreeUndo", 1).replacen("10 20 m", "500 20 m", 1); + let own = "/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r49 /StoryIndex ,\r/Art :\r1 2 m\r3 2 L\r3 4 L\rN\r;\r/ConfiningPath ,\r;\r"; + let f = text_frames(format!("{look}{own}").as_bytes()); + assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(1.0, 2.0, 3.0, 4.0)); assert!(text_frames(b"/AI11Text : garbage").is_empty()); } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index e82a534d..b1cc85db 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1015,6 +1015,12 @@ impl<'a> Reader<'a> { if self.pattern.is_some() { self.unreadable("type in a pattern"); } + // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its + // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its + // stories on its own; the object itself follows the look, on `%_` lines, and is the type. + if o.nums("FreeUndo").first() == Some(&1.0) { + return Ok(()); + } let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); let gs = self.gs.clone(); @@ -1280,9 +1286,20 @@ impl<'a> Reader<'a> { if !self.hidden_ids.contains(&object.id) || self.hidden_ids.contains(&look.id) || !matches!(look.kind, NodeKind::Group { clip: false, .. }) { return; } + let is_text = slot_of(object.name.as_deref()).is_some(); let Some(object) = f.children.pop() else { return }; let Some(look) = f.children.last_mut() else { return }; strip(look, &self.hidden_ids); + if is_text { + // A text object stays the type it is: over what else its look draws, or in place of a + // look that was only its type. + if look.children().is_some_and(|c| c.is_empty()) { + *look = object; + } else { + f.children.push(object); + } + return; + } look.name = object.name.clone().or(look.name.take()); look.visible = object.visible; look.locked = object.locked; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 3a468d8c..a999182c 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -459,6 +459,20 @@ fn text_objects_are_slots_named_after_their_story() { let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1)))); let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect(); assert_eq!(stories, [Some(Some(0)), Some(Some(1))]); + // A text object with an appearance: its drawn look (a group whose type is a story of the + // undo-free text document, which counts its own), then the object on `%_` lines, which is + // the type. + let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n0 0 Xd\n6 () XW\n"; + let own: String = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n7 /StoryIndex ,\n;\n".lines().map(|l| format!("%_{l}\n")).collect(); + let plain = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n"; + let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n{plain}"))); + let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect(); + assert_eq!(stories, [Some(Some(7)), Some(Some(0))], "the look's story 0 isn't the text document's"); + // What else the look draws stays, under the type. + let look = look.replace("U\n", &format!("{RED}{}f\nU\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n"))); + let a = art(&r.doc); + assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7)))); } #[test] diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 15a8e6c2..8d416f66 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -453,10 +453,11 @@ fn plan( Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect()) }) .collect(); - // The page's objects that are where a shown slot's lines are, nearest first. + // The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show + // has them too: a file's page may draw what its layers have hidden since. let mut pairs: Vec<(f64, usize, usize)> = vec![]; - for (k, info) in infos.iter().enumerate() { - let Some(l) = lines.get(k).and_then(Option::as_ref).filter(|_| info.shown) else { continue }; + for (k, _) in infos.iter().enumerate() { + let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue }; for (j, object) in shown.iter().enumerate() { if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { pairs.push((d, k, j)); @@ -472,6 +473,8 @@ fn plan( } // Type the text document makes, shown or not: the page's pieces of it aren't needed. let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); + // Where the made type that doesn't show lies, by slot. + let mut hidden_at: Vec<(usize, Rect)> = vec![]; if let Some(t) = template { for (k, info) in infos.iter().enumerate() { let Some(Some(story)) = stories.get(k) else { continue }; @@ -493,6 +496,9 @@ fn plan( TextKind::Point => {} } } + if let (false, Some(r)) = (info.shown, region) { + hidden_at.push((k, r)); + } if let Some(c) = content.get_mut(k) { *c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]); } @@ -506,9 +512,26 @@ fn plan( if !trusted { content.iter_mut().for_each(|c| *c = Content::Empty); } + // Is `object` the type of a text object that doesn't show, as the page draws it: in its frame, + // with its words? (A copied object's lines are where its original's are, so they don't say.) + let hidden_type = |object: &[Arc]| { + let Some(r) = extent(object) else { return false }; + let words: Vec = + object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect(); + hidden_at.iter().any(|(k, place)| { + place.intersect(r).area() >= 0.5 * r.area().max(1e-9) + && lines + .get(*k) + .and_then(Option::as_ref) + .is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) + }) + }; let mut spare: Vec>> = vec![]; for (j, object) in shown.iter().enumerate() { match owner.get(j).copied().flatten().filter(|_| trusted) { + // The type of a text object the layers hide: the layers have it, hidden, so the page's + // isn't another text object's. + Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {} Some(k) => match content.get_mut(k) { Some(Content::Made(_, _, seen, _)) => *seen = true, Some(Content::Page(p)) => p.extend(object.iter().cloned()), @@ -526,6 +549,7 @@ fn plan( } } } + None if trusted && hidden_type(object) => {} None => spare.push(object.clone()), } } @@ -857,7 +881,22 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) -> text_objects(c, l.visible, &mut shown, &mut hidden); } } + // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each + // goes where the layers have that artboard. + let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); + let same = + |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; + let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards); for object in shown.iter_mut().chain(hidden.iter_mut()) { + // The page with most of it, the first for what is on none. + let on = extent(object).and_then(|r| { + let inside = |p: &Rect| p.intersect(r).area().max(0.0); + pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1))) + }); + let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { + Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), + None => shift, + }; for n in object.iter_mut() { Arc::make_mut(n).transform(shift, false); } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index f15dc5d6..d11e78eb 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -491,6 +491,20 @@ fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() { assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings); } +#[test] +fn type_the_page_draws_of_a_text_object_on_a_hidden_layer_stays_hidden() { + // A file's page may draw what its layers have hidden since: story 1's type is on the page at + // (58.5, 30), and its text object is on a layer that doesn't show. + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Extra\r", 0, (8200.0, 8211.5), (0.0, 0.0), 0.0)]); + let art = native_with_text(&(layer("Art", true, &text_object(0)) + &layer("Spare", false, &text_object(1))), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Spare"], "{:?}", r.warnings); + let texts = |l: usize| text_of(&r.document.layers[l]).iter().map(|t| t.plain_text()).collect::>(); + assert_eq!((texts(0), texts(1)), (vec!["Hi there".to_string()], vec!["Extra".to_string()]), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings); +} + #[test] fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() { // The file writes some stories twice (plain, then as comments) and others only as comments. @@ -587,6 +601,37 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() { assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err()); } +/// A `.ai`'s PDF part has a page for each artboard, in a row: the type on a later page goes to +/// its text object where the layers have that artboard, not by the first page's place. +#[test] +fn type_on_a_later_artboard_of_an_ai_file_goes_to_its_text_object() { + // Two 100-point artboards, the second 50 below the first; the type is 50 down the second + // (art space: (58.5, -100); the canvas centre 8191.5 is the template box's centre, (50, 50)). + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8341.5), (0.0, 0.0), 0.0)]); + let art = native_with_text(&(boards.to_string() + &layer("Art", true, &(square(10, 10) + &text_object(0)))), &doc); + // The PDF part: the second page 20 points right of the first, the type 50 down it. + let mut pdf = pdf_part(10.0, 86.0); + let mut page = pdf.artboards[0].clone(); + (page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + pdf.artboards.push(page); + let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(178.5, 50.0), "Hi there", style); + let (id, layer_id) = (pdf.alloc_id(), pdf.layers[0].id); + pdf.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false); + assert!(read, "{w:?}"); + assert_eq!(d.artboards[1].rect, vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)); + let texts = text_of(&d.layers[0]); + assert_eq!(texts.len(), 1, "{w:?}"); + let [.., e, f] = texts[0].xf.as_coeffs(); + assert!((e - 58.5).abs() < 1e-6 && (f - 200.0).abs() < 1e-6, "{:?}", texts[0].xf); + assert!(!w.iter().any(|w| w.contains("with another's") || w.contains("doesn't draw")), "{w:?}"); +} + /// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its /// PDF part, which has only the top-level layers (#1025). #[test] From 0cf59a6cbd2ee716babfd765dd6f9fe0139e00f6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 18:42:36 +0700 Subject: [PATCH 03/13] .ai import: area type shows its first line in a substituted font (follow-up to #1264) Area type whose frame is too short for a line of the font it shows in here drew nothing: a font that isn't available shows in the fallback font, whose line may be taller than a frame made for one line of the file's font (overset type isn't drawn). Where the file itself lays lines out in the frame, the frame of the imported text object is made taller, from its top, by what its first line needs, and the open result says how many frames were. --- crates/eps/src/import/ate.rs | 55 +++++++++++++++++++++++++++++ crates/eps/src/import/native.rs | 11 ++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 14 files changed, 78 insertions(+) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 0e950176..147b320a 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -666,6 +666,35 @@ impl Story { Some(Node::new(id, NodeKind::Text(Box::new(t)))) } + /// Area type that the file lays lines out in, in a frame too short for its first line as it is + /// laid out here: a font that isn't available shows in the fallback font, whose lines may be + /// taller, and type that doesn't fit its frame isn't drawn. The frame is made taller, from its + /// top, by what the first line needs. Whether it was. + pub(super) fn fit_first_line(&self, t: &mut TextObject) -> bool { + let TextKind::Area { frame } = &t.kind else { return false }; + let db = vectorcraft_text::FontDb::global(); + let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6 && !self.lines.is_empty()) else { return false }; + if !vectorcraft_text::layout(db, t).lines.is_empty() { + return false; + } + // The height of its first line: the line's as point type. + let point = TextObject { kind: TextKind::Point, ..t.clone() }; + let Some(need) = vectorcraft_text::layout(db, &point).lines.first().map(|l| l.ascent + l.descent) else { return false }; + // (A little more than it, for the rounding of the layout.) + let by = (need + 0.05) / b.height(); + if !(by.is_finite() && by > 1.0) { + return false; + } + let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0))); + let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller }); + // Something else keeps the line out (a frame narrower than a letter): as the file has it. + if vectorcraft_text::layout(db, t).lines.is_empty() { + t.kind = short; + return false; + } + true + } + /// The leading the file's own line positions imply: the distance from one line to the next, less /// the space between paragraphs that is part of it (a paragraph's space after and the next one's /// space before; a layout's lines are where that space has put them, and the text object adds it @@ -860,4 +889,30 @@ mod tests { // One line has no distance to read. assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None); } + + #[test] + fn a_frame_too_short_for_its_first_line_is_made_taller() { + // A heading in a frame as tall as its size: a line of the font it shows in here is taller. + let s = story("Heading\r", &[0.0], &Para::default()); + let area = |height: f64| { + let mut t = TextObject::point(Point::ZERO, "Heading", CharStyle { size: 13.0, ..CharStyle::default() }); + t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 20.0 + height)) }; + t + }; + let height = + |t: &TextObject| if let TextKind::Area { frame } = &t.kind { frame.bounds().map(|b| (b.x0, b.y0, b.x1, b.height())) } else { None }; + let lines = |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines.len(); + let mut t = area(13.0); + assert_eq!(lines(&t), 0, "nothing of it shows"); + assert!(s.fit_first_line(&mut t)); + let (x0, y0, x1, h) = height(&t).unwrap(); + assert!((x0, y0, x1) == (10.0, 20.0, 210.0) && h > 13.0 && h < 26.0, "taller from its top: {h}"); + assert_eq!(lines(&t), 1); + // A frame that holds the line stays as it is; so does one the file lays no line out in + // (its type is overset in the file too). + let mut t = area(40.0); + assert!(!s.fit_first_line(&mut t) && height(&t).unwrap().3 == 40.0); + let mut t = area(13.0); + assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0); + } } diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 8d416f66..40ffc6e7 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -475,6 +475,7 @@ fn plan( let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); // Where the made type that doesn't show lies, by slot. let mut hidden_at: Vec<(usize, Rect)> = vec![]; + let mut taller = 0; if let Some(t) = template { for (k, info) in infos.iter().enumerate() { let Some(Some(story)) = stories.get(k) else { continue }; @@ -499,11 +500,21 @@ fn plan( if let (false, Some(r)) = (info.shown, region) { hidden_at.push((k, r)); } + if let NodeKind::Text(text) = &mut node.kind + && story.fit_first_line(text) + { + taller += 1; + } if let Some(c) = content.get_mut(k) { *c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]); } } } + if taller > 0 { + warn.push(format!( + "area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise" + )); + } // The file's text document is trusted where it agrees with the page about something (its lines // start where the page's type does, or the page has type where it lays type out), or where the // page has no type to disagree with. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c3fd1ecf..c25d957a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4129,6 +4129,7 @@ effect Regular Pravidelný @msg an image VectorCraft can't read was left out obrázek, který VectorCraft neumí přečíst, byl vynechán @msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel propojené obrázky se zobrazují jako náhled v nízkém rozlišení uložený v souboru, dokud se nenajdou jejich soubory: znovu je propojte v panelu Propojení +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise text v oblasti v rámcích příliš nízkých pro řádek písma, ve kterém se zde zobrazuje, dostal vyšší rámce ({_1}): jinak by nebyl vidět @msg its editing data doesn't say where its art is z jeho dat pro úpravy není zřejmé, kde je kresba @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) tento soubor byl uložen bez kompatibility s PDF a jeho data pro úpravy nelze přečíst ({why}) @msg No fonts are missing in this document V tomto dokumentu nechybí žádná písma diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ca5714ed..e889bcee 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3844,6 +3844,7 @@ theme Appearance Erscheinungsbild @msg an image VectorCraft can't read was left out ein Bild, das VectorCraft nicht lesen kann, wurde ausgelassen @msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel verknüpfte Bilder zeigen die in der Datei gespeicherte Vorschau in niedriger Auflösung, bis ihre Dateien gefunden werden: Verknüpfen Sie sie im Bedienfeld „Verknüpfungen“ neu +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise Flächentext in Rahmen, die für eine Zeile der Schrift, in der er hier erscheint, zu niedrig sind, hat höhere Rahmen bekommen ({_1}): Sonst wäre der Text nicht zu sehen @msg its editing data doesn't say where its art is seine Bearbeitungsdaten geben nicht an, wo seine Grafik liegt @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) diese Datei wurde ohne PDF-Kompatibilität gespeichert, und ihre Bearbeitungsdaten können nicht gelesen werden ({why}) @msg No fonts are missing in this document In diesem Dokument fehlen keine Schriften diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fe95a802..1f7716f2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3944,6 +3944,7 @@ axis Both Ambos @msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer @msg an image whose data is cut short was left out se omitió una imagen con datos incompletos @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel las imágenes vinculadas muestran la vista previa de baja resolución guardada en el archivo hasta que se encuentren sus archivos: vuelva a vincularlas en el panel Vínculos +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise el texto de área en marcos demasiado bajos para una línea de la fuente en que se muestra recibió marcos más altos ({_1}): de otro modo no se vería @msg its editing data doesn't say where its art is sus datos de edición no dicen dónde está su ilustración @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) este archivo se guardó sin compatibilidad con PDF y sus datos de edición no se pueden leer ({why}) @msg No fonts are missing in this document No faltan fuentes en este documento diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index eb0193ef..22ac83f9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3941,6 +3941,7 @@ axis Both Les deux @msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée @msg an image whose data is cut short was left out une image aux données tronquées a été ignorée @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel les images liées affichent l’aperçu basse résolution enregistré dans le fichier tant que leurs fichiers ne sont pas trouvés : liez-les à nouveau dans le panneau Liens +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise le texte captif dans des cadres trop bas pour une ligne de la police dans laquelle il s’affiche a reçu des cadres plus hauts ({_1}) : il ne s’afficherait pas autrement @msg its editing data doesn't say where its art is ses données d’édition ne disent pas où se trouve son illustration @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) ce fichier a été enregistré sans compatibilité PDF et ses données d’édition sont illisibles ({why}) @msg No fonts are missing in this document Aucune police ne manque dans ce document diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6c52b398..22354688 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4028,6 +4028,7 @@ theme Appearance Kinézet @msg an image VectorCraft can't read was left out kimaradt egy kép, amelyet a VectorCraft nem tud olvasni @msg an image whose data is cut short was left out kimaradt egy csonka adatú kép @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel a csatolt képek a fájlban mentett kis felbontású előnézetet mutatják, amíg a fájljaik elő nem kerülnek: csatolja őket újra a Csatolások panelen +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise a túl alacsony keretben lévő területszöveg, amelybe a megjelenítő betűtípus egy sora sem fér el, magasabb keretet kapott ({_1}): különben nem látszana @msg an object can't be read egy objektum nem olvasható @msg an object in perspective would leave the edited grid egy perspektivikus objektum kikerülne a szerkesztett rácsból @msg anchor must name a registered panel az anchor egy regisztrált panelt kell megnevezzen diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8c3f3e86..23ccf705 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3944,6 +3944,7 @@ axis Both Entrambi @msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa @msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel le immagini collegate mostrano l'anteprima a bassa risoluzione salvata nel file finché i loro file non vengono trovati: ricollegale nel pannello Collegamenti +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise il testo in area in cornici troppo basse per una riga del font in cui viene mostrato ha ricevuto cornici più alte ({_1}): altrimenti non sarebbe visibile @msg its editing data doesn't say where its art is i suoi dati di modifica non dicono dove si trova la grafica @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) questo file è stato salvato senza compatibilità PDF e i suoi dati di modifica non si possono leggere ({why}) @msg No fonts are missing in this document In questo documento non mancano font diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 37f98cda..0e72f49c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3680,6 +3680,7 @@ theme Appearance 外観 @msg an image VectorCraft can't read was left out VectorCraft で読み込めない画像は除外されました @msg an image whose data is cut short was left out データが途中で切れている画像は除外されました @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel リンク画像は、元のファイルが見つかるまで、ファイルに保存された低解像度のプレビューで表示されます:リンクパネルで再リンクしてください +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 表示に使われるフォントの 1 行に対して低すぎるフレーム内のエリア内文字は、フレームを高くしました ({_1}): そうしないと文字が表示されません @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません @msg patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2b6924d2..b9754ed9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3658,6 +3658,7 @@ theme Appearance 외관 @msg an image VectorCraft can't read was left out VectorCraft가 읽을 수 없는 이미지는 제외했습니다 @msg an image whose data is cut short was left out 데이터가 잘린 이미지는 제외했습니다 @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 연결된 그림은 해당 파일을 찾을 때까지 파일에 저장된 저해상도 미리 보기로 표시됩니다: 링크 패널에서 다시 연결하세요 +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 표시되는 글꼴의 한 줄보다 낮은 프레임의 영역 문자는 프레임을 더 높였습니다({_1}). 그렇지 않으면 문자가 표시되지 않습니다 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 그레이디언트 “{name}”이(가) 파일에 정의되어 있지 않아 단색으로 대체했습니다 @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다 @msg patterns nested too deeply are filled with mid-grey 너무 깊게 중첩된 패턴은 중간 회색으로 채웁니다 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 75234821..4f8f17fa 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3940,6 +3940,7 @@ axis Both Оба @msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено @msg an image whose data is cut short was left out изображение с обрезанными данными пропущено @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel связанные изображения показываются как сохранённый в файле просмотр низкого разрешения, пока их файлы не найдены: восстановите связи на панели «Связи» +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise текст в области в рамках, слишком низких для строки шрифта, которым он отображается, получил более высокие рамки ({_1}): иначе он не был бы виден @msg its editing data doesn't say where its art is в его данных редактирования не указано, где находится графика @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) этот файл сохранён без совместимости с PDF, и его данные редактирования не удаётся прочитать ({why}) @msg No fonts are missing in this document В этом документе нет отсутствующих шрифтов diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b55044ea..b634b79f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3928,6 +3928,7 @@ axis Both Обидві @msg an image VectorCraft can't read was left out зображення, яке VectorCraft не може прочитати, пропущено @msg an image whose data is cut short was left out зображення з обрізаними даними пропущено @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel пов’язані зображення показуються як збережений у файлі перегляд низької роздільності, доки їхні файли не знайдено: відновіть зв’язки на панелі «Зв’язки» +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise текст в області в рамках, занизьких для рядка шрифту, яким він показується, отримав вищі рамки ({_1}): інакше його не було б видно @msg its editing data doesn't say where its art is у даних редагування не зазначено, де знаходиться графіка @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) цей файл збережений без сумісності з PDF, і його дані редагування не вдається прочитати ({why}) @msg No fonts are missing in this document У цьому документі немає відсутніх шрифтів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7165eb80..1cfbfb57 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2937,6 +2937,7 @@ {count} linked image(s) changed on disk. Show the new versions? 有 {count} 个链接图像已在磁盘上更改。要显示新版本吗? @msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 个在磁盘上更改的链接文件 @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 链接的图片在找到其文件之前显示文件中存储的低分辨率预览:请在“链接”面板中重新链接 +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 框架高度不足以容纳所显示字体一行的区域文字已加高框架 ({_1}):否则文字不会显示 Update Modified Links 更新已修改的链接 Updated {} image(s) 已更新 {} 张图像 UPPERCASE 大写 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3aac3bd2..20156cee 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3030,6 +3030,7 @@ {count} linked image(s) changed on disk. Show the new versions? 有 {count} 個連結影像已在磁碟上變更。要顯示新版本嗎? @msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 個在磁碟上變更的連結檔案 @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 連結的圖片在找到其檔案之前顯示檔案中儲存的低解析度預覽:請在「連結」面板中重新連結 +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 框架高度不足以容納所顯示字體一行的區域文字已加高框架 ({_1}):否則文字不會顯示 Update Modified Links 更新已修改的連結 Updated {} image(s) 已更新 {} 張影像 UPPERCASE 全部大寫 From 4744c2f04b2414b7dc625eed5d52986e46cf4491 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Mon, 12 Oct 2026 17:40:19 +0700 Subject: [PATCH 04/13] Fix #1373: --control without a valid port stops the desktop before it starts --control took the next argument as its port and dropped it when it wasn't a number, so the app started without a control server and `--control --automation-read-root DIR` opened DIR as a document. automation_args::ControlArg now reads --control or --control= once and requires a port from 1 to 65535; VECTORCRAFT_CONTROL_PORT must be one too, checked only when --control isn't given so --control and --version win over a bad value. Errors go through the same exit as the root flags (#1157). Tests: unit tests in automation_args.rs; tests/automation_args.rs runs the binary for each bad form and checks the exact message and a prompt failing exit. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/vectorcraft/src/automation_args.rs | 99 ++++++++++++++++++++++- apps/vectorcraft/src/main.rs | 19 ++++- apps/vectorcraft/tests/automation_args.rs | 26 ++++++ docs/control-protocol.md | 4 +- docs/development.md | 2 +- 5 files changed, 142 insertions(+), 8 deletions(-) diff --git a/apps/vectorcraft/src/automation_args.rs b/apps/vectorcraft/src/automation_args.rs index b2a5d9f0..26157b76 100644 --- a/apps/vectorcraft/src/automation_args.rs +++ b/apps/vectorcraft/src/automation_args.rs @@ -1,6 +1,8 @@ -//! Parse automation roots before starting any desktop services. A malformed flag must not -//! silently remove a root and leave the control channel unconfined. +//! Parse the control port and automation roots before starting any desktop services. A malformed +//! flag must not silently remove a root and leave the control channel unconfined, or leave the app +//! running without the control channel that was asked for. +use std::ffi::OsString; use std::path::PathBuf; pub struct AutomationArgs { @@ -46,6 +48,50 @@ impl AutomationArgs { } } +/// `--control ` or `--control=`, given at most once. +#[derive(Default)] +pub struct ControlArg { + pub port: Option, +} + +impl ControlArg { + /// Returns whether this argument is `--control`. The next argument is its port whatever it + /// is, so `--control --automation-read-root` reports the flag rather than reading it as a root. + pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { + let value = match arg.strip_prefix("--control") { + Some("") => args.next(), + Some(rest) => match rest.strip_prefix('=') { + Some(value) => Some(value.to_owned()), + None => return Ok(false), + }, + None => return Ok(false), + }; + if self.port.is_some() { + return Err("--control given twice".into()); + } + self.port = Some(port("--control", value.as_deref())?); + Ok(true) + } +} + +/// `VECTORCRAFT_CONTROL_PORT`, for when `--control` isn't given: unset or empty is no control +/// channel, anything else must be a port. +pub fn env_control_port(value: Option) -> Result, String> { + match value { + Some(v) if !v.is_empty() => port("VECTORCRAFT_CONTROL_PORT", Some(&v.to_string_lossy())).map(Some), + _ => Ok(None), + } +} + +/// A port from 1 to 65535 (the server would listen on 0's random port without saying which). +fn port(name: &str, value: Option<&str>) -> Result { + let need = format!("{name} needs a port number from 1 to 65535"); + match value { + None => Err(need), + Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")), + } +} + #[cfg(test)] mod tests { use super::*; @@ -87,4 +133,53 @@ mod tests { assert_eq!(roots.read_root, Some("-folder".into())); assert_eq!(roots.write_root, Some("./-folder".into())); } + + fn control(args: &[&str]) -> Result, String> { + let mut control = ControlArg::default(); + let mut args = args.iter().map(|a| a.to_string()); + while let Some(a) = args.next() { + assert!(control.consume(&a, &mut args)?, "{a}"); + } + Ok(control.port) + } + + #[test] + fn control_needs_a_port_from_1_to_65535() { + let need = "--control needs a port number from 1 to 65535"; + assert_eq!(control(&["--control"]), Err(need.into())); + assert_eq!(control(&["--control", "--automation-read-root"]), Err(format!("{need}, not `--automation-read-root`"))); + for bad in ["abc", "0", "70000", "-1", ""] { + assert_eq!(control(&["--control", bad]), Err(format!("{need}, not `{bad}`"))); + assert_eq!(control(&[&format!("--control={bad}")]), Err(format!("{need}, not `{bad}`"))); + } + assert_eq!(control(&["--control", "7979"]), Ok(Some(7979))); + assert_eq!(control(&["--control=65535"]), Ok(Some(65535))); + assert_eq!(control(&["--control", "7979", "--control=7980"]), Err("--control given twice".into())); + } + + #[test] + fn other_arguments_are_not_control() { + let mut control = ControlArg::default(); + let mut args = ["7979".to_owned()].into_iter(); + for other in ["--controls", "--control-port", "drawing.svg", "--in-window-menus"] { + assert!(!control.consume(other, &mut args).unwrap(), "{other}"); + } + assert_eq!(args.next().as_deref(), Some("7979")); + assert_eq!(control.port, None); + } + + #[test] + fn control_port_variable_is_a_port_or_unset() { + assert_eq!(env_control_port(None), Ok(None)); + assert_eq!(env_control_port(Some("".into())), Ok(None)); + assert_eq!(env_control_port(Some("7979".into())), Ok(Some(7979))); + let need = "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535"; + assert_eq!(env_control_port(Some("abc".into())), Err(format!("{need}, not `abc`"))); + assert_eq!(env_control_port(Some("0".into())), Err(format!("{need}, not `0`"))); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + assert_eq!(env_control_port(Some(OsString::from_vec(vec![0x39, 0xff]))), Err(format!("{need}, not `9\u{fffd}`"))); + } + } } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 07a5d94f..2fb4695d 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -6,7 +6,8 @@ //! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on //! macOS instead of the macOS menu bar (`mac_menu`). //! -//! `--control ` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: +//! `--control ` (or `VECTORCRAFT_CONTROL_PORT`; a port from 1 to 65535, or the app doesn't +//! start) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. //! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and //! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the @@ -605,7 +606,7 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { fn main() -> std::process::ExitCode { // Validate roots before logging, font scans, preferences or the window: a missing value // must not clear an environment root and start the control channel without confinement. - let mut control_port: Option = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut control = automation_args::ControlArg::default(); let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from); let mut roots_args = automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT")); @@ -613,7 +614,7 @@ fn main() -> std::process::ExitCode { let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); let mut args = std::env::args().skip(1); while let Some(a) = args.next() { - match roots_args.consume(&a, &mut args) { + match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { Ok(true) => continue, Ok(false) => {} Err(e) => { @@ -622,7 +623,6 @@ fn main() -> std::process::ExitCode { } } match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), "--in-window-menus" => in_window_menus = true, "--version" => { println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); @@ -632,6 +632,17 @@ fn main() -> std::process::ExitCode { } } let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args; + // After the arguments: `--control` and `--version` win over a bad environment value. + let control_port = match control.port { + Some(port) => Some(port), + None => match automation_args::env_control_port(std::env::var_os("VECTORCRAFT_CONTROL_PORT")) { + Ok(port) => port, + Err(e) => { + eprintln!("vectorcraft: {e}"); + return std::process::ExitCode::FAILURE; + } + }, + }; // Before desktop services, so every start-up warning is recorded (`logging`). let logger = logging::install(); vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); diff --git a/apps/vectorcraft/tests/automation_args.rs b/apps/vectorcraft/tests/automation_args.rs index 8d2c667e..ffb8773e 100644 --- a/apps/vectorcraft/tests/automation_args.rs +++ b/apps/vectorcraft/tests/automation_args.rs @@ -9,12 +9,17 @@ const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft"); /// Invalid roots must exit before desktop initialization on every platform, including a CI /// machine with no display. A regression must fail rather than hang with a window open. fn rejects(args: &[&str], message: &str) { + rejects_with_env(args, &[], message); +} + +fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) { let mut child = Command::new(BIN) .args(args) .env_remove("VECTORCRAFT_CONTROL_PORT") .env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT") .env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT") .env("VECTORCRAFT_NO_PREFS", "1") + .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -68,3 +73,24 @@ fn explicit_dash_folder_does_not_consume_version() { assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); assert!(out.stderr.is_empty()); } + +/// `--control` without a port no longer starts the app without a control server, and the +/// argument after it isn't reinterpreted (#1373). +#[test] +fn control_without_a_valid_port_fails_before_startup() { + let need = "--control needs a port number from 1 to 65535"; + rejects(&["--control", "--automation-read-root", ".", "--automation-write-root", "."], &format!("{need}, not `--automation-read-root`")); + rejects(&["--control"], need); + rejects(&["--control", "abc"], &format!("{need}, not `abc`")); + rejects(&["--control=0"], &format!("{need}, not `0`")); + rejects(&["--control="], &format!("{need}, not ``")); + rejects(&["--control", "7979", "--control", "7980"], "--control given twice"); + rejects_with_env(&[], &[("VECTORCRAFT_CONTROL_PORT", "abc")], "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535, not `abc`"); +} + +#[test] +fn version_wins_over_a_bad_control_port_variable() { + let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 719d8d0b..77c429ee 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,8 @@ # Control protocol -`vectorcraft --control ` listens on `127.0.0.1:` (loopback only). One JSON request per line: +`vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number +from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits +before it starts. One JSON request per line: `{"id": 1, "method": "ui.inspect", "params": {}}` → `{"id": 1, "ok": true, "result": {...}}` or `{"id":1,"ok":false,"error":"..."}`. Set `VECTORCRAFT_CONFIG_DIR` to a dedicated directory for an isolated native test profile. Preferences, diff --git a/docs/development.md b/docs/development.md index 665246a9..6bd3d391 100644 --- a/docs/development.md +++ b/docs/development.md @@ -78,7 +78,7 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`. | Variable | Effect | |---|---| -| `VECTORCRAFT_CONTROL_PORT` | Same as `--control ` | +| `VECTORCRAFT_CONTROL_PORT` | Same as `--control `, which overrides it; a value that isn't a port from 1 to 65535 stops the app | | `VECTORCRAFT_IN_WINDOW_MENUS` | Any value but empty or `0`: same as `--in-window-menus` (see [macOS: the menu bar](#macos-the-menu-bar)) | | `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file (agents' test runs) | | `WGPU_POWER_PREF` | Graphics adapter: `low`, `high` or `none` (see [Desktop graphics processor](#desktop-graphics-processor)) | From de777a1b094a0cea4810f37d52808379e207d082 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Mon, 12 Oct 2026 17:40:19 +0700 Subject: [PATCH 05/13] Contributors: Nael-Nathanael's entry (GitHub profile name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 783d8271..4a5b3694 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -86,3 +86,6 @@ sync_github_name = false real_name = "Aaron Guo" display_name = "Aaron Guo" sync_github_name = false + +[people.Nael-Nathanael] +sync_github_name = true From a9a7a4cddf22b75a869d7dce330325e346cba4c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 12 Oct 2026 02:52:01 -0700 Subject: [PATCH 06/13] Fix #1257: a dragged object reuses its filter and shadow rasters Each pointer move of a drag applies the move to the interaction's snapshot, so the dragged object is a new node in every frame. The renderer kept the rasters of an object's drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) only for the same node, so every frame of a drag ran those effects again. On main, a moved frame of texture_drag_perf (a CMYK rounded rectangle at 190.07% and DPI 1.5 in an 1800 x 1500 view) took 112 to 197 ms in a release build. The canvas now renders with RenderOptions::interacting while a drag or a dialog's preview is in progress. When an object misses its cached raster in such a frame, the renderer takes the raster an earlier frame made for an object with the same id, slot and ink at the same zoom and rotation, made by the same effects from content of the same size (to within 0.000001 pt). It draws the object's content into a raster placed as that one was and compares the pixels byte for byte (only the alpha under a shadow or glow). For Feather it also compares the points of the clip outline, to within 0.000001 pt. When they match, the old raster is drawn at the new place, rounded to a whole pixel, as a pan draws a cached raster. Content under De-Interlace never takes a moved raster, and a raster made over moved rasters counts as moved itself. A frame rendered without interacting never draws such a raster, or a raster made over one: the frame after the drag runs the effects again and draws what a fresh renderer draws. The canvas's cache key includes whether an interaction is in progress. On the desktop, the canvas sends the frame after an interaction to the render worker when the frame shown drew moved rasters. Each cached raster keeps the content it was made from, within the existing 512 MiB budget. The pixels kept with a filtered raster take as many bytes as the raster, and the alpha kept with a shadow or glow takes a quarter as many as the shadow. texture_drag_perf now renders a drag's frames as the canvas does, then the frame after the drag, and that frame once more with every raster cached. A moved frame now takes 20 to 26 ms, close to the 18 to 25 ms of the cached frame; the frame after the drag takes 110 to 189 ms. Docs: docs/target-app-parity.md. --- crates/render/src/fx.rs | 461 ++++++++++++++++++++++++++-- crates/render/src/lib.rs | 10 + crates/ui-egui/src/canvas.rs | 96 +++++- crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/render_worker.rs | 4 +- docs/target-app-parity.md | 2 + 6 files changed, 550 insertions(+), 30 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 94b06bba..969fae8c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -18,6 +18,10 @@ //! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't //! change. //! +//! While [`crate::RenderOptions::interacting`], an object whose content only moved draws its +//! cached shadows, glows and filtered content at its new place instead of running the effects +//! again ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]). +//! //! The same applies to one fill or stroke's own raster effects (around that item alone) and to //! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is //! drawn offscreen in the filter layers, reshaped first by their geometry effects. Groups and @@ -27,9 +31,10 @@ use std::sync::Arc; -use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind}; +use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId, NodeKind}; +use vectorcraft_effects::pixel::{Channels, PixelFx}; use vectorcraft_effects::{self as effects, GeomContext, RasterFx}; -use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape, Vec2}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathEl, Point, Rect, Shape, Size, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::RenderContext; use vello_cpu::peniko; @@ -230,9 +235,21 @@ pub(crate) struct PixelEntry { rel: (f64, f64), k: f64, image: std::sync::Arc, + made: Made, + /// Made for the object before it moved ([`Renderer::move_pixel_content`]), or made over + /// rasters made before their objects moved: only a frame rendered while interacting draws + /// it. + moved: bool, pub(crate) stamp: u64, } +impl PixelEntry { + /// The bytes it holds, for [`FX_RASTER_BUDGET`]. + pub(crate) fn bytes(&self) -> usize { + raster_bytes(&self.image) + self.made.bytes() + } +} + /// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys. fn linear_key(view: Affine) -> [u64; 4] { let c = view.as_coeffs(); @@ -260,9 +277,87 @@ pub(crate) struct ShadowEntry { /// Pixel offset of the raster's top-left from the anchor's view position. rel: (f64, f64), image: std::sync::Arc, + made: Made, + /// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters + /// made before their objects moved: only a frame rendered while interacting draws it. + moved: bool, pub(crate) stamp: u64, } +impl ShadowEntry { + /// The bytes it holds, for [`FX_RASTER_BUDGET`]. + pub(crate) fn bytes(&self) -> usize { + raster_bytes(&self.image) + self.made.bytes() + } +} + +/// What a cached shadow, glow or filtered raster was made from. While +/// [`crate::RenderOptions::interacting`], an object whose content only moved after the raster +/// was made draws from that raster ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]). +pub(crate) struct Made { + /// The content's pixels as drawn into the raster before the effects ran: premultiplied RGBA, + /// or their alpha alone under a shadow or glow. + pixels: Vec, + /// The effects run on them. + fx: Vec, + /// The size of the content's document-space bounds. + size: Size, + /// The color channels the Photoshop-style filters were told of (none for shadows and glows). + channels: Option, + /// Feather's clip outline, moved to put the raster's anchor at the origin. + outline: Option<(BezPath, FillRule)>, +} + +/// How far (document units) the size of a moved object's content and the points of its Feather +/// outline may lie from those of the content a raster was made from. +const MOVED_SLACK: f64 = 1e-6; + +impl Made { + /// The bytes it holds beyond the raster. + fn bytes(&self) -> usize { + self.pixels.len() + self.outline.as_ref().map_or(0, |(g, _)| crate::lru::path_bytes(g)) + } + + /// Whether content of size `size` under the effects `fx`, told of `channels`, with Feather's + /// `outline` (moved to put its anchor at the origin) may be this content moved: the same + /// effects, channels and outline rule, and the size and outline within [`MOVED_SLACK`]. + fn fits(&self, fx: &[&RasterFx], size: Size, channels: Option, outline: Option<&(BezPath, FillRule)>) -> bool { + let near = |a: f64, b: f64| (a - b).abs() <= MOVED_SLACK; + self.fx.len() == fx.len() + && self.fx.iter().zip(fx).all(|(a, b)| a == *b) + && near(self.size.width, size.width) + && near(self.size.height, size.height) + && self.channels == channels + && match (&self.outline, outline) { + (None, None) => true, + (Some((a, ra)), Some((b, rb))) => ra == rb && same_path(a, b), + _ => false, + } + } +} + +/// Whether `a` and `b` have the same elements with points within [`MOVED_SLACK`] of each other. +fn same_path(a: &BezPath, b: &BezPath) -> bool { + let near = |p: &Point, q: &Point| (*p - *q).hypot() <= MOVED_SLACK; + a.elements().len() == b.elements().len() + && a.elements().iter().zip(b.elements()).all(|pair| match pair { + (PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(p, q), + (PathEl::QuadTo(p0, p1), PathEl::QuadTo(q0, q1)) => near(p0, q0) && near(p1, q1), + (PathEl::CurveTo(p0, p1, p2), PathEl::CurveTo(q0, q1, q2)) => near(p0, q0) && near(p1, q1) && near(p2, q2), + (PathEl::ClosePath, PathEl::ClosePath) => true, + _ => false, + }) +} + +/// Feather's clip outline of `content` when `chain` feathers it, moved to put `anchor` at the +/// origin ([`Made::outline`]). +fn feather_outline(chain: &[&RasterFx], content: &Content, anchor: Point) -> Option<(BezPath, FillRule)> { + let (g, rule) = content.outline.filter(|_| chain.iter().any(|fx| matches!(fx, RasterFx::Feather { .. })))?; + let mut g = g.clone(); + g.apply_affine(Affine::translate(-anchor.to_vec2())); + Some((g, rule)) +} + /// The object a cached raster was made for: the drawn `Arc` itself when known (kept alive, so its /// address, the cache key, names it alone and a key match is exact), else a copy compared by value. pub(crate) enum Drawn { @@ -285,10 +380,18 @@ impl Drawn { Self::Copy(c) => **c == *n, } } + + /// The id of the object drawn. + fn id(&self) -> NodeId { + match self { + Self::Shared(a) => a.id, + Self::Copy(c) => c.id, + } + } } -/// Most bytes the shadow and filtered-content rasters of one renderer hold together: the least -/// recently drawn go beyond it. +/// Most bytes the shadow and filtered-content rasters of one renderer hold together, with what each +/// was made from ([`Made`]): the least recently drawn go beyond it. pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20; fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize { @@ -308,8 +411,9 @@ pub(crate) struct FxGeom { impl Renderer { /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster: /// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused - /// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when - /// the effect isn't a shadow/glow or the raster would be too large (caller falls back). + /// while the object and the zoom/rotation are unchanged (pans only move it), and while + /// interacting after the object only moved ([`Self::move_shadow`]). Returns false when the + /// effect isn't a shadow/glow or the raster would be too large (caller falls back). #[allow(clippy::too_many_arguments)] fn draw_shadow_cached( &mut self, @@ -330,10 +434,16 @@ impl Renderer { let linear = linear_key(f.view); let key = (n as *const Node as usize, i, f.ink); let stamp = self.stamp; - let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node.is(n)); + let interacting = f.opts.interacting; + let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && (interacting || !e.moved); + if interacting && !self.shadows.get(&key).is_some_and(exact) { + self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, paint); + } + let hit = self.shadows.get_mut(&key).filter(|e| exact(e)); let (image, x, y) = match hit { Some(e) => { e.stamp = stamp; + self.stats.moved += usize::from(e.moved); let p = f.view * e.anchor; (e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round()) } @@ -350,10 +460,16 @@ impl Renderer { // Silhouette (the object's painted alpha), offset by the shadow distance. let mut off = f.offscreen_context(w, h); let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + let before = self.stats.moved; self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); + // A silhouette drawn over moved rasters makes a moved shadow. + let moved = self.stats.moved > before; off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); + // What the raster is made from, for its object once it moves (`move_shadow`). + let made = + Made { pixels: pm.data().iter().map(|p| p.a).collect(), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None }; let (wu, hu) = (w as usize, h as usize); let mut a: Vec = pm.data().iter().map(|p| p.a as f32).collect(); effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px); @@ -367,7 +483,7 @@ impl Renderer { let anchor = reach.origin(); let p = f.view * anchor; let node = Drawn::of(n, owner); - self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp }); + self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), made, moved, stamp }); self.trim_fx_rasters(FX_RASTER_BUDGET); (image, x0, y0) } @@ -385,6 +501,54 @@ impl Renderer { true } + /// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier + /// frame made for its object (the same id, effect slot and ink, at this zoom and rotation) when + /// only the silhouette's place changed after that frame: made by the same effect `fx` from a + /// silhouette whose bounds have the size of `reach` (the content's bounds moved by the + /// shadow's offset), and whose alpha is the alpha of `n`'s silhouette drawn into a raster + /// placed as that one was against its anchor. The raster is kept as `n`'s, marked moved, and + /// is drawn at the nearest whole pixel. + #[allow(clippy::too_many_arguments)] + fn move_shadow( + &mut self, + f: &Frame, + n: &Node, + key: (usize, usize, Ink), + fx: &RasterFx, + reach: Rect, + owner: Option<&Arc>, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + let (dx, dy) = match fx { + RasterFx::DropShadow { dx, dy, .. } => (*dx, *dy), + _ => (0.0, 0.0), + }; + let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size()); + let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| { + (k.1, k.2) == (key.1, key.2) && e.stamp < stamp && e.linear == linear && e.node.id() == n.id && e.made.fits(&[fx], size, None, None) + }; + let Some((old, rel, w, h)) = self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height())) + else { + return; + }; + // The silhouette drawn where the old one lies in its raster. + let anchor = reach.origin(); + let p = f.view * anchor; + let mut off = f.offscreen_context(w, h); + let crop = Frame { mt: false, view: Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f }; + self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + if !self.shadows.get(&old).is_some_and(|e| pm.data().iter().map(|p| p.a).eq(e.made.pixels.iter().copied())) { + return; + } + if let Some(mut e) = self.shadows.remove(&old) { + (e.node, e.anchor, e.moved) = (Drawn::of(n, owner), anchor, true); + self.shadows.insert(key, e); + } + } + /// `draw_shape` for nodes with live effects. pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { // The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path, @@ -628,7 +792,8 @@ impl Renderer { /// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels: /// larger content is cropped to the view when every effect only looks nearby, else rendered /// at a lower resolution. A whole raster at full resolution is cached while the object and the - /// zoom stay the same (panning only moves it). + /// zoom stay the same (panning only moves it), and while interacting after the content only + /// moved ([`Self::move_pixel_content`]). fn pixel_content( &mut self, ctx: &mut RenderContext, @@ -664,8 +829,23 @@ impl Renderer { let linear = linear_key(f.view); let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink)); let stamp = self.stamp; - if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node.is(content.node)) { + let channels = match f.ink { + Ink::Cmy => effects::pixel::Channels::CmyPlane, + Ink::K => effects::pixel::Channels::KPlane, + Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk, + Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, + }; + let interacting = f.opts.interacting; + let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && (interacting || !e.moved); + if interacting + && let Some(key) = key + && !self.pixel_fx.get(&key).is_some_and(exact) + { + self.move_pixel_content(f, content, key, &chain, channels, paint); + } + if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| exact(e)) { e.stamp = stamp; + self.stats.moved += usize::from(e.moved); let p = f.view * e.anchor; let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k); draw_scaled(ctx, image, at, k); @@ -677,16 +857,21 @@ impl Renderer { let px = f.px / k; let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; let mut off = f.offscreen_context(wu, hu); + let before = self.stats.moved; self.inside_layer(|r| paint(r, &mut off, &frame)); + // Content drawn over moved rasters makes a moved raster. + let moved = self.stats.moved > before; off.flush(); let mut pm = vello_cpu::Pixmap::new(wu, hu); off.render(&mut pm, &mut self.resources); - let channels = match f.ink { - Ink::Cmy => effects::pixel::Channels::CmyPlane, - Ink::K => effects::pixel::Channels::KPlane, - Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk, - Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, - }; + // What the raster is made from, for its object once it moves (`move_pixel_content`). + let made = key.map(|_| Made { + pixels: pm.data_as_u8_slice().to_vec(), + fx: chain.iter().map(|fx| (*fx).clone()).collect(), + size: content.reach.size(), + channels: Some(channels), + outline: feather_outline(&chain, content, content.reach.origin()), + }); // De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi). let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height())); @@ -706,23 +891,77 @@ impl Renderer { } } let image = std::sync::Arc::new(pm); - if let Some(key) = key { + if let Some((key, made)) = key.zip(made) { let anchor = content.reach.origin(); let p = f.view * anchor; let rel = (x0 - p.x, y0 - p.y); let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); let node = Drawn::of(content.node, owner.as_ref()); - self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), stamp }); + self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, moved, stamp }); self.trim_fx_rasters(FX_RASTER_BUDGET); } draw_scaled(ctx, image, (x0, y0), k); } + /// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame + /// made at full size for its object (the same id, slot and ink, at this zoom and rotation) + /// when only the content's place changed after that frame: made by the same effects `chain`, + /// told of the same `channels`, from content whose bounds have the same size (and the same + /// Feather outline against its anchor), and whose pixels are the pixels of `content` drawn + /// into a raster placed as that one was against its anchor. The raster is kept as + /// `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under + /// De-Interlace takes none. + fn move_pixel_content( + &mut self, + f: &Frame, + content: &Content, + key: (usize, usize, Ink), + chain: &[&RasterFx], + channels: Channels, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) { + return; + } + let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin()); + let outline = feather_outline(chain, content, anchor); + let fits = |k: &(usize, usize, Ink), e: &PixelEntry| { + (k.1, k.2) == (key.1, key.2) + && e.stamp < stamp + && e.linear == linear + && e.k >= 1.0 + && e.node.id() == content.node.id + && e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref()) + }; + let Some((old, rel, w, h)) = self.pixel_fx.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height())) + else { + return; + }; + // The content drawn where the old content lies in its raster. + let p = f.view * anchor; + let view = Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let frame = Frame { mt: false, view, visible, ..*f }; + let mut off = f.offscreen_context(w, h); + self.inside_layer(|r| paint(r, &mut off, &frame)); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + if !self.pixel_fx.get(&old).is_some_and(|e| e.made.pixels.as_slice() == pm.data_as_u8_slice()) { + return; + } + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); + if let Some(mut e) = self.pixel_fx.remove(&old) { + (e.node, e.anchor, e.moved) = (Drawn::of(content.node, owner.as_ref()), anchor, true); + self.pixel_fx.insert(key, e); + } + } + /// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes /// ([`FX_RASTER_BUDGET`]). fn trim_fx_rasters(&mut self, budget: usize) { - let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, raster_bytes(&e.image))); - let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, raster_bytes(&e.image))); + let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, e.bytes())); + let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, e.bytes())); let mut all: Vec<_> = shadows.chain(pixels).collect(); let mut total: usize = all.iter().map(|e| e.3).sum(); if total <= budget { @@ -891,7 +1130,7 @@ mod tests { use vectorcraft_doc::{Appearance, Document, Effect, Node}; use vectorcraft_geom::{Affine, Rect, shapes}; - use super::{Drawn, raster_bytes}; + use super::Drawn; use crate::{RenderOptions, Rendered, Renderer}; fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document { @@ -976,8 +1215,8 @@ mod tests { // A frame of other objects makes rasters of its own; the older ones go first. let _ = r.render(&effects_page(), 400, 400, Affine::scale(2.0), &opts); let newest = r.stamp; - let bytes = |r: &Renderer| r.shadows.values().map(|e| raster_bytes(&e.image)).sum::(); - let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| raster_bytes(&e.image)).sum::(); + let bytes = |r: &Renderer| r.shadows.values().map(|e| e.bytes()).sum::(); + let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| e.bytes()).sum::(); assert!(bytes(&r) > latest); r.trim_fx_rasters(latest); assert_eq!(bytes(&r), latest); @@ -1202,7 +1441,163 @@ mod tests { assert_eq!(b.pixels, c.pixels); } - /// Reproduce #1257's moving, filtered rectangle without reusing a static frame's raster. + /// While interacting, an object that only moved draws its drop shadow and filtered content + /// from the rasters of an earlier frame, at the nearest whole pixel, without running the + /// effects again (#1257). The frame after the interaction runs them again: it is what a fresh + /// renderer draws. + #[test] + fn moved_objects_draw_their_effect_rasters_while_interacting() { + let effects = + vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "opacity": 80})), ("texture.craquelure", json!({"crackSpacing": 6}))]; + let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), effects); + let id = base.layers[0].children().unwrap()[0].id; + // The object moved from where it started, as each frame of a drag moves it. + let moved = |dx: f64, dy: f64| { + let mut d = base.clone(); + d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false); + d + }; + let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let live = RenderOptions { interacting: true, ..still.clone() }; + let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still).pixels; + let mut r = Renderer::new(); + assert!(r.render(&base, 200, 200, view, &still).pixels == fresh(&base)); + assert_eq!(r.stats.moved, 0); + let (mut d, mut last) = (base.clone(), vec![]); + for (dx, dy) in [(3.0, 0.0), (3.3, 1.2), (5.15, 2.5), (6.3, 2.2)] { + d = moved(dx, dy); + last = r.render(&d, 200, 200, view, &live).pixels; + assert_eq!(r.stats.moved, 2, "the shadow and the filtered content moved by ({dx}, {dy})"); + // At the nearest whole pixel (2 a point): what a fresh renderer draws there. + let whole = moved((dx * 2.0).round() / 2.0, (dy * 2.0).round() / 2.0); + assert!(last == fresh(&whole), "moved by ({dx}, {dy})"); + } + // The frame after the drag, of the same document: the effects run again where the object is. + let after = r.render(&d, 200, 200, view, &still).pixels; + assert_eq!(r.stats.moved, 0); + assert!(after == fresh(&d), "the frame after the drag"); + assert!(after != last, "6.3 is not on a whole pixel"); + } + + /// While interacting, a raster moves with its object only when the content's pixels (for a + /// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under + /// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object, + /// art added beyond the old raster, a changed Feather outline and De-Interlace run the effects + /// again. A shadow keeps its raster under a new fill color. + #[test] + fn moved_rasters_need_the_same_content_and_effects() { + let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let live = RenderOptions { interacting: true, ..still.clone() }; + // `before` drawn, then `after` while interacting: the rasters drawn moved, and whether the + // frame is what a fresh renderer draws. + let drag = |before: &Document, after: &Document| { + let mut r = Renderer::new(); + let _ = r.render(before, 200, 200, view, &still); + let frame = r.render(after, 200, 200, view, &live).pixels; + (r.stats.moved, frame == Renderer::new().render(after, 200, 200, view, &still).pixels) + }; + // `d`'s object moved 5, 3 (10, 6 pixels), then changed by `edit`. + let moved = |d: &Document, patterns: bool, edit: &dyn Fn(&mut Node)| { + let mut d = d.clone(); + let id = d.layers[0].children().unwrap()[0].id; + let n = d.node_mut(id).unwrap(); + n.transform(Affine::translate((5.0, 3.0)), vectorcraft_doc::Scaling { patterns, ..Default::default() }); + edit(n); + d + }; + let cracks = || vec![("texture.craquelure", json!({"crackSpacing": 6}))]; + let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), cracks()); + assert_eq!(drag(&base, &moved(&base, false, &|_| {})), (1, true), "moved"); + let recolor = |n: &mut Node| n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.1, 0.4, 0.9)); + assert_eq!(drag(&base, &moved(&base, false, &recolor)), (0, true), "recolored"); + let spacing = |n: &mut Node| n.appearance.effects[0].params = json!({"crackSpacing": 9}); + assert_eq!(drag(&base, &moved(&base, false, &spacing)), (0, true), "other effect settings"); + let resize = |n: &mut Node| n.transform(Affine::scale_about(1.5, vectorcraft_geom::Point::new(25.0, 23.0)), false); + assert_eq!(drag(&base, &moved(&base, false, &resize)), (0, true), "resized"); + // A square added beyond the old raster leaves the content inside it as it was. + let beyond = |n: &mut Node| { + if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind { + path.subpaths.extend(shapes::rectangle(Rect::new(75.0, 30.0, 80.0, 35.0)).subpaths); + } + }; + assert_eq!(drag(&base, &moved(&base, false, &beyond)), (0, true), "art added beyond the old raster"); + // At 72 ppi, De-Interlace works on lines 1 point tall. The object moves 3 points down, so + // De-Interlace makes its other field again. + let fields = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("video.deinterlace", json!({}))]); + assert_eq!(drag(&fields, &moved(&fields, false, &|_| {})), (0, true), "De-Interlace"); + // A pattern fill: its tiles stay on the page unless they move with the object. + let mut tiled = base.clone(); + let red = Node::path( + vectorcraft_doc::NodeId(900), + shapes::rectangle(Rect::new(0.0, 0.0, 3.0, 3.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + let mut def = vectorcraft_doc::pattern::PatternDef::new("Dots", vec![std::sync::Arc::new(red)]); + def.tile = Rect::new(0.0, 0.0, 7.0, 7.0); + tiled.patterns.push(def); + let id = tiled.layers[0].children().unwrap()[0].id; + tiled.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = vectorcraft_doc::pattern::pattern_paint("Dots"); + assert_eq!(drag(&tiled, &moved(&tiled, false, &|_| {})), (0, true), "tiles that stay on the page"); + assert_eq!(drag(&tiled, &moved(&tiled, true, &|_| {})), (1, true), "tiles that move with the object"); + // Feather clips to the outline: a notch in the top edge where the tiles paint nothing + // (x 29 to 33 and y 20 to 24 once moved) leaves the content's pixels as they were. + let mut feathered = tiled.clone(); + feathered.node_mut(id).unwrap().appearance.effects = + vec![Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }]; + assert_eq!(drag(&feathered, &moved(&feathered, true, &|_| {})), (1, true), "feathered"); + let notch = |n: &mut Node| { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((25.0, 23.0)); + for p in [(30.0, 23.0), (30.5, 23.6), (31.5, 23.6), (32.0, 23.0), (55.0, 23.0), (55.0, 49.0), (25.0, 49.0)] { + bp.line_to(p); + } + bp.close_path(); + if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind { + *path = vectorcraft_geom::PathData::from_bezpath(&bp); + } + }; + assert_eq!(drag(&feathered, &moved(&feathered, true, ¬ch)), (0, true), "a notch in the feathered outline"); + // A shadow is made from the silhouette's alpha: recolored, the object takes its shadow + // along; notched, the object makes its shadow again. + let shadowed = + doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2}))]); + assert_eq!(drag(&shadowed, &moved(&shadowed, false, &recolor)), (1, true), "a shadow under a new color"); + assert_eq!(drag(&shadowed, &moved(&shadowed, false, ¬ch)), (0, true), "a shadow under a notch"); + } + + /// A raster made over moved rasters counts as moved itself (#1257): a group with a drop shadow + /// and Gaussian Blur of its own, whose filtered member moves by parts of a pixel while the + /// other member stays, draws after the drag what a fresh renderer draws. + #[test] + fn rasters_made_over_moved_rasters_count_as_moved() { + let mut d = Document::new(100.0, 100.0); + let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let (sid, mid, gid) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let still = Node::path(sid, shapes::rectangle(Rect::new(60.0, 60.0, 80.0, 80.0)), fill(Color::rgb(0.1, 0.5, 0.2))); + let mut member = Node::path(mid, shapes::rectangle(Rect::new(20.0, 20.0, 50.0, 46.0)), fill(Color::rgb(0.9, 0.4, 0.1))); + member.appearance.effects = vec![Effect { id: "texture.craquelure".into(), params: json!({"crackSpacing": 6}), visible: true }]; + let mut group = Node::group(gid, vec![std::sync::Arc::new(still), std::sync::Arc::new(member)]); + group.appearance.effects = vec![ + Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 3, "blur": 2}), visible: true }, + Effect { id: "blur.gaussian".into(), params: json!({"radius": 1}), visible: true }, + ]; + let l = d.layers[0].id; + d.insert(Some(l), 0, group).unwrap(); + let (view, opts) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let mut r = Renderer::new(); + let _ = r.render(&d, 200, 200, view, &opts); + let mut moved = d.clone(); + moved.node_mut(mid).unwrap().transform(Affine::translate((3.3, 1.2)), false); + let _ = r.render(&moved, 200, 200, view, &RenderOptions { interacting: true, ..opts.clone() }); + assert!(r.stats.moved > 0, "the member's filtered content moved"); + let after = r.render(&moved, 200, 200, view, &opts).pixels; + assert!(after == Renderer::new().render(&moved, 200, 200, view, &opts).pixels, "the frame after the drag"); + } + + /// Reproduce #1257's moving, filtered rectangle: the first frame renders as before a drag, + /// and the next eight render as the canvas renders a drag's frames (`interacting`). Then the + /// eighth position renders as the canvas renders the frame after the drag, and that frame + /// renders once more with every raster cached. /// Run with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`. #[test] #[ignore = "manual timing probe; wall-clock performance depends on machine load"] @@ -1222,23 +1617,33 @@ mod tests { ]; base.insert(Some(layer), 0, n).unwrap(); let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let live = RenderOptions { interacting: true, ..opts.clone() }; let view = Affine::scale(1.9007 * 1.5); let mut r = Renderer::new(); r.threads = crate::default_threads(); - let mut ms = vec![]; + let (mut ms, mut doc) = (vec![], base.clone()); for frame in 0..9 { - let mut doc = base.clone(); + doc = base.clone(); doc.node_mut(id).unwrap().transform(Affine::translate((3.0 + f64::from(frame) * 0.75, f64::from(frame) * 0.5)), false); let start = std::time::Instant::now(); - let image = r.render(&doc, 1800, 1500, view, &opts); + let image = r.render(&doc, 1800, 1500, view, if frame > 0 { &live } else { &opts }); let time = start.elapsed().as_secs_f64() * 1000.0; if frame > 0 { ms.push(time); } std::hint::black_box(image); } + let mut time = |o: &RenderOptions| { + let start = std::time::Instant::now(); + std::hint::black_box(r.render(&doc, 1800, 1500, view, o)); + start.elapsed().as_secs_f64() * 1000.0 + }; + let (after, again) = (time(&opts), time(&opts)); ms.sort_by(f64::total_cmp); - println!("{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}", (ms[3] + ms[4]) * 0.5); + println!( + "{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}; the frame after the drag {after:.2} ms, that frame again {again:.2} ms", + (ms[3] + ms[4]) * 0.5 + ); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 238a25cc..74f0e9fc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -127,6 +127,12 @@ pub struct RenderOptions { /// (box-filtered from the full image, kept with the decoded images), so the full image needn't /// stay in memory or be sampled. Off, images always sample their full pixels (exports). pub image_mips: bool, + /// Screen views while an interaction is in progress (a drag, a dialog's preview): an object + /// whose content only moved since an earlier frame draws its cached drop shadows, outer glows + /// and filtered content from the rasters made then, at the nearest whole pixel, without + /// running the effects again (see `fx`). Off, no raster made that way, or made over one, is + /// drawn: the effects run again. + pub interacting: bool, /// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB /// black. Off, it shows through the CMYK profile, a dark grey, as other colours do. pub rich_black: bool, @@ -204,6 +210,7 @@ impl Default for RenderOptions { precise: false, supersample: false, image_mips: false, + interacting: false, rich_black: false, } } @@ -444,6 +451,9 @@ static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic: pub struct FrameStats { pub drawn: usize, pub culled: usize, + /// The drop shadows, outer glows and filtered contents the frame drew from rasters made before + /// their object moved, or made over such rasters ([`RenderOptions::interacting`]). + pub moved: usize, pub micros: u64, } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1546da4e..373e6939 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -237,6 +237,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let key = CacheKey { doc: st.uid as usize, revision: st.revision, + interacting: st.interaction.is_some(), zoom: v.zoom, cx: v.center.x, cy: v.center.y, @@ -273,6 +274,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.canvas.key = Some(done.key); app.perf.render_ms = done.ms; app.canvas.last_ms = done.ms; + app.canvas.moved = done.moved > 0; } if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() { let (w, h, view) = match pixel { @@ -307,13 +309,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { isolated: key.isolated.map(vectorcraft_doc::NodeId), // Images far smaller on screen than they are sample their mip levels (#1244). image_mips: true, + // During a drag or a dialog's preview, objects that only moved draw their raster + // effects from the cached rasters of an earlier frame (#1257). + interacting: key.interacting, rich_black: key.rich_black, ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. // A document switch renders synchronously so another document's frame is never shown. + // The first frame after an interaction, when the frame shown drew moved rasters, also goes + // to the worker. let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc); - let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some() && same_doc; + let settling = app.canvas.moved && !key.interacting; + let heavy = (app.canvas.last_ms > 8.0 || settling) && app.canvas.texture.is_some() && same_doc; match (&mut app.canvas.worker, heavy) { (Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), _ => { @@ -323,6 +331,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.canvas.key = Some(key.clone()); app.perf.render_ms = now_ms() - t0; app.canvas.last_ms = app.perf.render_ms; + app.canvas.moved = app.canvas.renderer.stats.moved > 0; } } } @@ -2882,6 +2891,91 @@ mod tests { assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again"); } + /// A drag of an object with raster effects (#1257): the drag's frames render while + /// interacting and draw the object's drop shadow and filtered content from the rasters of the + /// frame before; the frame after the drag renders without `interacting`, on the render worker, + /// and draws what a fresh renderer draws. + #[test] + fn dragging_an_object_with_raster_effects_draws_its_rasters_moved() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone(); + for effect in ["stylize.dropShadow", "texture.craquelure"] { + app.run("effect.apply", json!({"effect": effect, "ids": [id]})).unwrap(); + } + let ctx = egui::Context::default(); + // The art's raster uploaded by one frame, None when it was not re-rendered. + let upload = |app: &mut VectorcraftApp| -> Option> { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.clone() + }); + delta.clear(); + px + }; + upload(&mut app).expect("the first frame renders the art"); + app.select_tool("selection"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap(); + for step in [1.0, 2.0, 3.0] { + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 160.0 + 7.3 * step, 125.0 + 2.1 * step), view).unwrap(); + upload(&mut app).expect("each frame of the drag renders"); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "step {step}"); + assert_eq!(app.canvas.renderer.stats.moved, 2, "the shadow and the filtered content, step {step}"); + assert!(app.canvas.moved); + } + // The frame after the drag runs the effects again: on the worker, while the canvas shows + // the drag's last frame. + app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap(); + assert!(upload(&mut app).is_none(), "not rendered on this thread"); + assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker"); + let until = std::time::Instant::now() + std::time::Duration::from_secs(10); + let mut after = None; + while after.is_none() && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(5)); + after = upload(&mut app); + } + let after = after.expect("the worker's frame arrives"); + assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting)); + assert!(!app.canvas.moved); + app.canvas.worker = None; + app.canvas.renderer = vectorcraft_render::Renderer::new(); + app.canvas.key = None; + assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws"); + } + + /// The frame after an interaction renders without `interacting` also when the interaction + /// leaves the document as it was (#1257): here a drag back to where it started. + #[test] + fn a_drag_back_to_its_start_renders_again_after_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone(); + app.run("effect.apply", json!({"effect": "texture.craquelure", "ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); + app.select_tool("selection"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap(); + for (x, y) in [(173.3, 129.1), (160.0, 125.0)] { + app.session.pointer(&PointerEvent::new(PointerKind::Drag, x, y), view).unwrap(); + shapes(&mut app, &ctx); + assert_eq!(app.canvas.renderer.stats.moved, 1, "the filtered content at ({x}, {y})"); + } + let revision = app.session.active().unwrap().revision; + app.session.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 125.0), view).unwrap(); + assert_eq!(app.session.active().unwrap().revision, revision, "the drag left the document as it was"); + shapes(&mut app, &ctx); + assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting), "rendered again"); + assert_eq!(app.canvas.renderer.stats.moved, 0); + assert!(!app.canvas.moved); + } + /// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel /// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the /// canvas always does out of Pixel Preview. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 25fc715d..30ecb89d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -267,6 +267,10 @@ pub struct CanvasCache { pub texture: Option, pub key: Option, pub last_ms: f64, + /// The frame shown drew raster effects from rasters made before their objects moved + /// ([`vectorcraft_render::FrameStats::moved`]): the next frame outside an interaction runs + /// them again, on the worker when there is one. + pub moved: bool, pub worker: Option, pub worker_started: bool, /// The slices as laid out for (document uid, revision): the canvas draws them every frame. @@ -291,6 +295,8 @@ pub type PrintTilingCache = ((u64, u64), std::sync::Arc Date: Mon, 12 Oct 2026 03:11:50 -0700 Subject: [PATCH 07/13] Render: the placed art of symbol instances and inline graphics is kept between frames The renderer drew each symbol instance by copying its symbol's art, staining the copy for a stained instance and moving every object in it by the instance's transform, in every frame. The copies were new nodes, so the caches the renderer keys by a node's address (cull bounds, path geometry, glyph layouts, live-effect geometry) missed for every object of every instance, and the copies stayed in those caches until they were pruned. Stroke outlines missed as well, because their cache uses an entry only when the entry holds the node being drawn. The renderer copied and moved the art of inline graphics in type the same way. The renderer now keeps the art it places in a frame cache with a 64 MiB budget, under the address of the symbol's art, the transform, whether strokes scale with it, and the instance's stain. When the art is a group or a layer without an appearance of its own, the renderer copies none of it in a frame whose instances and symbols did not change, and the objects in it hit the caches keyed by their own address (cull bounds and paths, glyph layouts, live-effect geometry). The stroke-outline cache keys a stroke by its address in the appearance. In the art placed for an unstained instance, each object without a gradient fill or stroke shares its appearance with the same object in the symbol's art. When a symbol has two or more unstained instances, the renderer therefore still outlines the strokes of those objects again for each instance in every frame. Moving or restaining an instance, or editing its symbol's art, places that art again, and the other instances keep theirs. Other art is placed again in every frame, as before, and so is art that holds a symbol instance, type with an inline graphic, an image, a placed document, a blend, an envelope, a repeat or a raster effect. Art that holds type is placed again when the fonts change. vectorcraft-cli bench of a symbol of eight paths (three filled and stroked, two only filled, three only stroked) shown by 2,000 unstained instances took 51.6 ms a frame on main and 32.3 ms with this change, at 2880x1800 on 8 threads; the same paths in 2,000 groups took 26.0 ms. The peak working set of a bench run at that size went from 150 MiB to 83 MiB. In the tests, a renderer that keeps placed art draws the same pixels as a new renderer, and instances draw the same pixels as groups of their placed art. --- crates/render/src/lib.rs | 113 +++++++++- crates/render/src/lru.rs | 2 + crates/render/src/tests_fontchange.rs | 22 ++ crates/render/src/tests_memory.rs | 307 +++++++++++++++++++++++++- 4 files changed, 438 insertions(+), 6 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 74f0e9fc..53b30762 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -359,6 +359,21 @@ struct GeomEntry { type ClipEntry = (Arc, Option>); /// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). type ClipPaintEntry = (Arc, Option>, Option>); +/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits +/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]). +type PlacedKey = (usize, [u64; 6], bool, [u32; 6]); + +/// Symbol art placed by [`Renderer::placed_art`]. +struct PlacedEntry { + /// The symbol's art, held weakly: the reference keeps the art's allocation, so no other art can + /// get its address while the entry exists, and does not keep the art's content. + art: std::sync::Weak, + /// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]). + placed: Option>, + /// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when + /// it holds type: the art is placed again when the fonts change. + fonts: Option, +} /// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded /// images are shared by all renderers, see [`set_image_cache_budget`]). @@ -407,6 +422,8 @@ pub struct Renderer { shape_of: usize, /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). fx_arts: FrameCache, Arc)>, + /// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]). + placed_arts: FrameCache, /// Layers open around the drawing point in the current context: a non-isolated group can copy /// its backdrop only when there are none (see [`Self::group`]). nested: u32, @@ -518,6 +535,7 @@ impl Renderer { knockout: false, shape_of: 0, fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256), + placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256), nested: 0, backdrop: None, blends: PtrMap::default(), @@ -621,6 +639,7 @@ impl Renderer { self.clips.sweep(g); self.clip_paints.sweep(g); self.fx_arts.sweep(g); + self.placed_arts.sweep(g); self.brushes.entries.sweep(g); self.live.sweep(g); } @@ -1146,8 +1165,7 @@ impl Renderer { NodeKind::Image(im) => self.draw_image(ctx, f, im), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { - let mut art = brush_fx::instance_art(&sym.art, n); - art.transform(*xf, false); + let art = self.placed_art(&sym.art, *xf, false, Some(n)); self.draw_node(ctx, f, &art, true); } } @@ -1481,14 +1499,46 @@ impl Renderer { for ig in &g.inlines { let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue }; - let mut node = (*sym.art).clone(); // Strokes scale with the art (it is sized to the type, as in the SVG ``). - node.transform(t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true); + let node = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None); self.draw_node(ctx, f, &node, true); } self.inline_depth -= 1; } + /// Symbol art `art` as an instance or an inline graphic draws it ([`place`]). Art that is a + /// group or a layer without an appearance of its own is kept between frames under the art's + /// `Arc`, `xf`, `scale_strokes` and the stain of instance `inst`: its members keep their `Arc`s + /// from frame to frame, and the caches keyed by a node's address (geometry, glyphs, live-effect + /// geometry) hit for them. Other art is placed again in every frame, and so is art that holds + /// symbol instances, type with inline graphics, images, placed documents, live blends, + /// envelopes or repeats, or raster effects ([`depends_on_document`]). Art that holds type is + /// placed again when the fonts change. + fn placed_art(&mut self, art: &Arc, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc { + if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || fx::has_object_fx(art) { + return place(art, xf, scale_strokes, inst); + } + let stain = stain_bits(inst.and_then(vectorcraft_brush::stain)); + let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain); + if let Some(e) = self.placed_arts.get(&key, self.stamp) + && std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art)) + && e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation()) + { + return match &e.placed { + Some(placed) => placed.clone(), + None => place(art, xf, scale_strokes, inst), + }; + } + let placed = place(art, xf, scale_strokes, inst); + let kept = (!depends_on_document(art)).then(|| placed.clone()); + let fonts = (kept.is_some() && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation()); + // The bytes count the key and the entry, the art's allocation that the weak reference + // keeps, and the kept art. + let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes); + self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp); + placed + } + /// Cached glyph geometry for a text node (keyed by Arc identity like paths). fn text_geom_of(&mut self, a: &Arc, t: &TextObject) -> Arc { let fonts = vectorcraft_text::FontDb::global().generation(); @@ -1854,6 +1904,61 @@ fn painted_items(n: &Node) -> usize { .max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None))) { 2 } else { 0 }) } +/// Symbol art `art` stained by instance `inst` ([`instance_art`]) and placed by `xf`, with +/// strokes scaled by `xf` when `scale_strokes`. +fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc { + let mut placed = match inst { + Some(i) => brush_fx::instance_art(art, i), + None => art.clone(), + }; + placed.transform(xf, scale_strokes); + Arc::new(placed) +} + +/// Does `n` hold, in it or in the opacity masks of the objects in it, a symbol instance, type +/// with an inline graphic, an image, a placed document, a blend, an envelope, a repeat, or an +/// object or a fill or stroke with a visible raster effect? +fn depends_on_document(n: &Node) -> bool { + let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && effects::is_raster(&e.id)); + let mut depends = false; + n.walk(&mut |c| { + depends |= match &c.kind { + NodeKind::SymbolInstance { .. } + | NodeKind::Image(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Repeat(_) => true, + NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()), + _ => false, + } || raster(&c.appearance.effects) + || c.appearance.items.iter().any(|i| raster(i.effects())) + || c.extras().mask.as_ref().is_some_and(|m| depends_on_document(&m.art)); + }); + depends +} + +/// Does `n` hold type, in it or in the opacity masks of the objects in it? +fn holds_type(n: &Node) -> bool { + let mut holds = false; + n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::Text(_)) || c.extras().mask.as_ref().is_some_and(|m| holds_type(&m.art))); + holds +} + +/// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color +/// model, the color's components and the amount (all 0 without a stain). +fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] { + use vectorcraft_color::Color; + let Some((color, amount)) = stain else { return [0; 6] }; + let (model, [p, q, r, s]) = match color { + Color::Rgb { r, g, b } => (1, [r, g, b, 0.0]), + Color::Cmyk { c, m, y, k } => (2, [c, m, y, k]), + Color::Gray { k } => (3, [k, 0.0, 0.0, 0.0]), + Color::Lab { l, a, b } => (4, [l, a, b, 0.0]), + }; + [model, p.to_bits(), q.to_bits(), r.to_bits(), s.to_bits(), amount.to_bits()] +} + /// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic. static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index d96dbc68..2a2e6292 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -30,6 +30,8 @@ pub(crate) const TEXT_BUDGET: usize = 128 << 20; pub(crate) const CLIP_BUDGET: usize = 32 << 20; /// The art drawn for objects with effects that apply through it (`Renderer::fx_art`). pub(crate) const FX_ART_BUDGET: usize = 64 << 20; +/// Symbol art placed for instances and inline graphics (`Renderer::placed_art`). +pub(crate) const PLACED_ART_BUDGET: usize = 64 << 20; /// Brush art per brushed stroke (`Renderer::draw_brush`). pub(crate) const BRUSH_BUDGET: usize = 64 << 20; /// Evaluated blends, envelopes and repeats (`Renderer::live_expanded`). diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs index 551ad3bf..b089aa17 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests_fontchange.rs @@ -39,3 +39,25 @@ fn type_redraws_when_its_font_becomes_available() { assert_ne!(before, after, "drawn in the font that arrived"); assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); } + +/// Type with a geometry effect in a symbol's art redraws in a font that becomes available: the art +/// kept for the instance is placed again when the fonts change. +#[test] +fn symbol_type_redraws_when_its_font_becomes_available() { + const FAMILY: &str = "Placed Serif 4"; + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() }; + let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(-140.0, 25.0), "Hamburg", style)))); + t.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true }); + let art = Node::group(d.alloc_id(), vec![Arc::new(t)]); + d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) })).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0); + let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert_ne!(before, after, "drawn in the font that arrived"); + assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); +} diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index acf5848c..174be50d 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -1,5 +1,5 @@ -//! What renderers keep between frames: decoded images shared by all of them, image mips, and -//! caches swept by every kind of render. +//! What renderers keep between frames: decoded images shared by all of them, image mips, symbol +//! art placed for instances and inline graphics, and caches swept by every kind of render. use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; @@ -174,6 +174,309 @@ fn swept_type_entries_give_back_their_bytes() { assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0)); } +/// A symbol's art inside the 20 × 20 square around the origin: a group of a square filled blue +/// with a 2 pt black stroke and a black line. +fn symbol_art(d: &mut Document) -> Node { + use vectorcraft_doc::Appearance; + let square = vectorcraft_geom::shapes::rectangle(Rect::new(-8.0, -8.0, 8.0, 8.0)); + let square = Node::path(d.alloc_id(), square, Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::BLACK), 2.0)); + let line = vectorcraft_geom::shapes::line((-8.0, 4.0).into(), (8.0, 4.0).into()); + let line = Node::path(d.alloc_id(), line, Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.5)); + Node::group(d.alloc_id(), vec![Arc::new(square), Arc::new(line)]) +} + +/// Three instances of symbol `S` ([`symbol_art`]): two in a row, the second stained red, and a +/// third turned and scaled by 1.5. +fn symbol_doc() -> Document { + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let l = d.layers[0].id; + let xfs = [ + Affine::translate((30.0, 30.0)), + Affine::translate((80.0, 30.0)), + Affine::translate((140.0, 60.0)) * Affine::rotate(0.5) * Affine::scale(1.5), + ]; + for (i, xf) in xfs.into_iter().enumerate() { + let mut inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + if i == 1 { + inst.appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + } + d.insert(Some(l), i, inst).unwrap(); + } + d +} + +/// The addresses of the nodes `r` keeps geometry for that are not the own nodes of any version +/// of the document drawn (`docs`): the symbol art it placed for instances and inline graphics. +fn placed_copies(r: &Renderer, docs: &[&Document]) -> std::collections::BTreeSet { + let mut own = std::collections::HashSet::new(); + for d in docs { + d.walk(|n| { + own.insert(n as *const Node as usize); + }); + } + r.geom.values().map(|e| Arc::as_ptr(&e.node) as usize).filter(|p| !own.contains(p)).collect() +} + +/// A second frame of an unchanged document draws the symbol art placed for its instances in the +/// first: it copies no art, and the geometry kept for the art's paths is used again. The renderer +/// holds the symbol's art itself only weakly. +#[test] +fn symbol_instances_keep_their_placed_art_between_frames() { + let d = symbol_doc(); + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + let placed = placed_copies(&r, &[&d]); + assert_eq!(placed.len(), 6, "the square and the line of three instances"); + assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first); + assert_eq!(placed_copies(&r, &[&d]), placed, "the second frame draws the art the first placed"); + assert_eq!(Arc::strong_count(&d.symbols[0].art), 1, "only the document holds the symbol's art"); +} + +/// Moving an instance places its art again, and editing a symbol's art places the art of its +/// instances again: the other instances keep theirs, and each frame draws what a new renderer +/// draws. +#[test] +fn editing_an_instance_or_a_symbol_places_only_its_art_again() { + let mut d = symbol_doc(); + // Symbol `T` is a green square, and one instance of it stands below the others. + let green = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0); + let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), green); + let t = Node::group(d.alloc_id(), vec![Arc::new(square)]); + d.symbols.push(vectorcraft_doc::Symbol { name: "T".into(), art: Arc::new(t) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "T".into(), xf: Affine::translate((30.0, 80.0)) })).unwrap(); + let opts = RenderOptions::default(); + let frame = |r: &mut Renderer, d: &Document| r.render(d, 200, 100, Affine::IDENTITY, &opts).pixels; + let mut r = Renderer::new(); + frame(&mut r, &d); + let before = placed_copies(&r, &[&d]); + assert_eq!(before.len(), 7); + // The first instance of `S` moves 5 pt right. + let unmoved = d.clone(); + let first = d.layers[0].children().unwrap()[1].id; + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(first).map(|n| &mut n.kind) { + *xf = Affine::translate((5.0, 0.0)) * *xf; + } + assert!(frame(&mut r, &d) == frame(&mut Renderer::new(), &d)); + let moved = placed_copies(&r, &[&unmoved, &d]); + assert!(before.is_subset(&moved) && moved.len() == before.len() + 2, "{} copies, then {}", before.len(), moved.len()); + // `S`'s square is repainted red: its instances show it, and `T`'s instance keeps its art. + let mut art = (*d.symbols[0].art).clone(); + if let Some(square) = art.children_mut().and_then(|c| c.first_mut()) { + Arc::make_mut(square).appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + } + d.symbols[0].art = Arc::new(art); + let red = frame(&mut r, &d); + assert!(red == frame(&mut Renderer::new(), &d)); + let img = Rendered { width: 200, height: 100, pixels: red }; + assert_eq!(img.pixel(140, 60), [255, 0, 0, 255], "the turned instance's square"); + let edited = placed_copies(&r, &[&unmoved, &d]); + assert!(moved.is_subset(&edited) && edited.len() == moved.len() + 6, "{} copies, then {}", moved.len(), edited.len()); +} + +/// Draws `d` (200 × 100 points, on white), applies `edit` and draws it again with the same +/// renderer: the frame changed, a new renderer draws the same, and only the document holds the +/// symbols' art. +fn edit_draws_as_new(mut d: Document, edit: impl FnOnce(&mut Document)) { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let before = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + edit(&mut d); + let after = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(after != before, "the edit changes the frame"); + assert!(after == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels, "a new renderer draws the same"); + assert!(d.symbols.iter().all(|s| Arc::strong_count(&s.art) == 1), "only the document holds the symbols' art"); +} + +/// `d` with symbol `Holder` (a group of `members`) shown by one instance at (100, 50). +fn holder_doc(mut d: Document, members: Vec) -> Document { + let holder = Node::group(d.alloc_id(), members.into_iter().map(Arc::new).collect()); + d.symbols.push(vectorcraft_doc::Symbol { name: "Holder".into(), art: Arc::new(holder) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Holder".into(), xf: Affine::translate((100.0, 50.0)) })).unwrap(); + d +} + +/// Symbol art that shows another symbol (type with an inline graphic of it, an instance of it with +/// a Transform effect) is placed again in every frame: when that symbol's art changes, the next +/// frame draws what a new renderer draws. +#[test] +fn art_showing_another_symbol_draws_its_edits() { + use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let style = CharStyle { size: 24.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(-60.0, 10.0), "A", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone())); + t.runs.push(TextRun::new("B", style)); + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let mut moved = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((40.0, 0.0)) }); + moved.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 4.0}), + visible: true, + }); + let d = holder_doc(d, vec![text, moved]); + // `S` becomes a wide red bar: the inline graphic grows, and the type after it moves. + edit_draws_as_new(d, |d| { + let bar = vectorcraft_geom::shapes::rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0)); + let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red)); + }); +} + +/// Symbol art with raster effects is placed again in every frame: when a pattern under a blur or +/// the document's raster resolution under De-Interlace changes, the next frame draws what a new +/// renderer draws. +#[test] +fn art_with_raster_effects_draws_pattern_and_resolution_edits() { + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + let effect = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true }; + let mut d = Document::new(200.0, 100.0); + d.raster_effects_ppi = 72.0; + // Pattern `Checks` is a red 10 × 10 square in a 20 × 20 tile. + let tile = |c: Color| { + let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + vec![Arc::new(Node::path(NodeId(900), square, vectorcraft_doc::Appearance::basic(Paint::solid(c), Paint::None, 0.0)))] + }; + let mut def = PatternDef::new("Checks", tile(Color::rgb(1.0, 0.0, 0.0))); + def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(def); + let band = vectorcraft_geom::shapes::rectangle(Rect::new(-90.0, -45.0, -20.0, -25.0)); + let mut blurred = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); + blurred.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 2.0}))); + // The path holds ten bars, 1 pt high and 2 pt apart. + let mut bars = vectorcraft_geom::shapes::rectangle(Rect::new(20.0, -40.0, 90.0, -39.0)); + for k in 1..10 { + let y = -40.0 + 2.0 * k as f64; + bars.subpaths.extend(vectorcraft_geom::shapes::rectangle(Rect::new(20.0, y, 90.0, y + 1.0)).subpaths); + } + let mut striped = Node::path(d.alloc_id(), bars, vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + striped.appearance.effects.push(effect("video.deinterlace", serde_json::json!({}))); + let d = holder_doc(d, vec![blurred, striped]); + edit_draws_as_new(d, |d| { + if let Some(def) = d.pattern_mut("Checks") { + def.art = tile(Color::rgb(0.0, 0.0, 1.0)); + } + d.raster_effects_ppi = 24.0; + }); +} + +/// Symbol art with a live object (an envelope that distorts a pattern fill) is placed again in +/// every frame: when the pattern's tile type changes, the next frame draws what a new renderer +/// draws. +#[test] +fn art_with_live_objects_draws_pattern_layout_edits() { + use vectorcraft_doc::live::EnvelopeOptions; + use vectorcraft_doc::pattern::{PatternDef, TileType, pattern_paint}; + let mut d = Document::new(200.0, 100.0); + // Pattern `Checks` is a red 10 × 10 square in a 20 × 20 tile. + let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut def = PatternDef::new("Checks", vec![Arc::new(Node::path(NodeId(900), square, red))]); + def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(def); + let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0)); + let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() }; + let envelope = Node::new( + d.alloc_id(), + NodeKind::Envelope { + content: vec![Arc::new(filled)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options, + frame: Box::new(Affine::IDENTITY), + }, + ); + let d = holder_doc(d, vec![envelope]); + edit_draws_as_new(d, |d| { + if let Some(def) = d.pattern_mut("Checks") { + def.tile_type = TileType::BrickByRow { offset: 0.5 }; + } + }); +} + +/// Symbol instances draw what groups of their art, stained and placed, draw: in every frame, when +/// two instances placed alike are stained differently, when the art is a single path, and with a +/// cache too small for all of the art. Art no longer drawn leaves the cache after a few frames. +#[test] +fn symbol_instances_draw_as_groups_of_their_placed_art() { + let mut d = symbol_doc(); + let l = d.layers[0].id; + // An instance placed like the first one lies over it, stained green. + let mut over = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((30.0, 30.0)) }); + over.appearance.set_fill(Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + d.insert(Some(l), 1, over).unwrap(); + // Symbol `U` is a single path, a circle with a black stroke. + let circle = vectorcraft_geom::shapes::ellipse(Rect::new(-6.0, -6.0, 6.0, 6.0)); + let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.8, 0.0)), Paint::solid(Color::BLACK), 1.0); + let circle = Node::path(d.alloc_id(), circle, ap); + d.symbols.push(vectorcraft_doc::Symbol { name: "U".into(), art: Arc::new(circle) }); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "U".into(), xf: Affine::translate((175.0, 25.0)) })).unwrap(); + // `groups` holds the same art in place of the instances: groups, and the circle as a path. + let mut groups = d.clone(); + let placed = d.layers[0].children().unwrap().iter().map(|c| match &c.kind { + NodeKind::SymbolInstance { symbol, xf } => { + let sym = d.symbols.iter().find(|s| s.name == *symbol).unwrap(); + let mut art = instance_art(&sym.art, c); + art.transform(*xf, false); + Arc::new(art) + } + _ => c.clone(), + }); + *groups.children_mut(Some(l)).unwrap() = placed.collect(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let want = Renderer::new().render(&groups, 200, 100, Affine::IDENTITY, &opts).pixels; + let mut r = Renderer::new(); + r.placed_arts = FrameCache::new(lru::PLACED_ART_BUDGET, 0); + for frame in 0..3 { + assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == want, "frame {frame}"); + } + // A cache that holds half the bytes of that art keeps what fits and places the rest every frame. + let all = r.placed_arts.bytes(); + let mut small = Renderer::new(); + small.placed_arts = FrameCache::new(all / 2, 0); + for frame in 0..3 { + assert!(small.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == want, "frame {frame} over the budget"); + assert!(!small.placed_arts.is_empty() && small.placed_arts.bytes() <= all / 2, "{} of {all} bytes", small.placed_arts.bytes()); + } + let empty = Document::new(10.0, 10.0); + for _ in 0..5 { + r.render(&empty, 10, 10, Affine::IDENTITY, &opts); + } + assert_eq!((r.placed_arts.len(), r.placed_arts.bytes()), (0, 0)); +} + +/// Inline graphics in type draw the art placed for them in an earlier frame, as instances do. +#[test] +fn inline_graphics_keep_their_placed_art_between_frames() { + use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let style = CharStyle { size: 24.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone())); + t.runs.push(TextRun::new("B", style)); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + let placed = placed_copies(&r, &[&d]); + assert_eq!(placed.len(), 2, "the square and the line of the inline graphic"); + assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first); + assert_eq!(placed_copies(&r, &[&d]), placed, "the second frame draws the art the first placed"); +} + /// Each run draws exactly the outlines it drew when kept as a path of its own: its glyphs and /// decorations in layout order (the whole text's path is kept once, the runs as spans of it). #[test] From 3073f0ab030dc41c8307d16e0a6f930ee8a2e095 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:52:32 +0300 Subject: [PATCH 08/13] Isolation mode: double-clicking a single object isolates it With Double Click To Isolate on, the Selection tool isolated groups only. A double-click on a single path, compound path, image, mesh or placed file now isolates it too, as the public documentation describes. Type still switches to the Type tool; blends, envelopes, repeats and symbol instances keep their own editing modes. While a single object is isolated only it is hit, marqueed and taken by Select All, new art goes beside it in its group or layer, and a double-click on the empty canvas leaves with it selected. object.isolate accepts any object except type. --- crates/doc/src/hit.rs | 20 ++++++++++-- crates/engine/src/cmd/object.rs | 6 ++-- crates/engine/src/cmd/select.rs | 3 +- crates/engine/src/lib.rs | 6 ++++ crates/engine/src/tests_prefs.rs | 54 ++++++++++++++++++++++++++++++++ crates/tools/src/corners.rs | 5 ++- crates/tools/src/select.rs | 17 +++++++--- docs/mcp.md | 5 +-- docs/ui-parity.md | 3 +- 9 files changed, 104 insertions(+), 15 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 226241c8..b74d7c2c 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -31,6 +31,10 @@ impl Hit { /// The object the Selection tool selects: the child of the layer (the outermost group). /// Inside isolation mode (`scope`), the child of the isolated container instead. pub fn top_object(&self, scope: Option) -> NodeId { + // A single object isolated: it is the only object. + if scope == Some(self.leaf) { + return self.leaf; + } // The innermost of the isolated container and an envelope whose contents are edited. let at = |s: Option| s.and_then(|s| self.ancestry.iter().position(|x| *x == s)); if let Some(i) = at(scope).max(at(self.contents_of)) { @@ -101,10 +105,16 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No // locked, and clicks pass through it (#833). if editable_only && let Some(scope) = opt.scope - && let Some(container) = doc.node(scope).filter(|n| n.is_container()) + && let Some(isolated) = doc.node(scope) && let Some(mut chain) = doc.ancestry(scope) { - return hit_children(container, p, opt, skip, &mut chain, editable_only).map(finish); + if isolated.is_container() { + return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish); + } + // A single object isolated (a path, an image…): only it hits, among its siblings. + chain.pop(); + let parent = chain.last().and_then(|id| doc.node(*id))?; + return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish); } let mut chain = Vec::new(); for layer in doc.layers.iter().rev() { @@ -283,7 +293,11 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option { pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { let mut out = Vec::new(); let tops: Vec<&std::sync::Arc> = match scope.and_then(|s| doc.node(s)) { - Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(), + Some(s) => match s.children() { + Some(c) => c.iter().collect(), + // A single object isolated: only it. + None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(), + }, // The objects of the layers, looking through sublayers (hidden, locked and template ones // are left out). None => doc diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 6928fa15..7de994ed 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -857,8 +857,10 @@ fn isolate(s: &mut Session, p: &Value) -> Result { .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("object.isolate", "missing id"))?; let st = s.doc_mut()?; - if st.doc.node(id).is_none_or(|n| !n.is_container()) { - return Err(bad("object.isolate", "only groups and layers can be isolated")); + // Groups, layers and single objects (a path, a compound path, an image…); type isn't + // isolated: a double-click on it edits its text. + if st.doc.node(id).is_none_or(|n| matches!(n.kind, NodeKind::Text(_))) { + return Err(bad("object.isolate", "type objects can't be isolated")); } let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); st.isolation = Some(isolation); diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 36d6fdb5..9db257c3 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -193,7 +193,8 @@ pub fn specs() -> Vec { fn selectable(d: &Document, iso: Option) -> Vec { match iso.and_then(|i| d.node(i)) { Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => { - c.layer_art(true) + // A single object isolated is all there is to select. + if c.is_container() { c.layer_art(true) } else { vec![c.id] } } Some(_) => vec![], None => d.selectable_art(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0bbfdeeb..2b0a390a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -483,6 +483,12 @@ impl DocState { { return Some(i); } + // A single object isolated: new art goes beside it, in its group or layer. + if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i)) + && self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) + { + return Some(p); + } // A sublayer takes new art only while it and the layers around it are shown and unlocked. self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer()) } diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index cd9374a2..9b831a02 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -298,6 +298,60 @@ fn selection_preferences_drive_the_selection_tool() { assert!(double(&mut s).is_some(), "on: the group is isolated"); } +/// Double Click To Isolate: a double-click with the Selection tool isolates a single object too, +/// not only a group. Inside, only it is hit, marqueed and selected by Select All; new art goes +/// beside it; a double-click on the empty canvas leaves, with it selected. Type isn't isolated: +/// a double-click on it switches to the Type tool. +#[test] +fn double_clicking_a_single_object_isolates_it() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = |v: Value| NodeId(v["id"].as_u64().unwrap()); + let rect = |s: &mut Session, x: f64| id(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 50, "height": 50})).unwrap()); + let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 200.0)); + s.select_tool("selection", ViewInfo::default()).unwrap(); + let ev = |s: &mut Session, kinds: &[PointerKind], x: f64, y: f64| { + for k in kinds { + s.pointer(&PointerEvent::new(*k, x, y), ViewInfo::default()).unwrap(); + } + }; + let click = [PointerKind::Down, PointerKind::Up]; + let double = [PointerKind::DoubleClick]; + ev(&mut s, &double, 125.0, 125.0); + assert_eq!(s.doc().unwrap().isolation, Some(a)); + // Only the isolated object is hit: a click on the other selects nothing. + ev(&mut s, &click, 225.0, 125.0); + assert!(s.doc().unwrap().selection.objects.is_empty()); + ev(&mut s, &click, 125.0, 125.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + // A marquee over both and Select All take it alone. + s.execute("select.none", &json!({})).unwrap(); + ev(&mut s, &[PointerKind::Down], 90.0, 90.0); + ev(&mut s, &[PointerKind::Move, PointerKind::Up], 300.0, 300.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + // A second double-click on it stays in isolation; new art lands on its layer. + ev(&mut s, &double, 125.0, 125.0); + assert_eq!(s.doc().unwrap().isolation, Some(a)); + let c = rect(&mut s, 300.0); + let st = s.doc().unwrap(); + assert_eq!(st.doc.parent_of(c), st.doc.parent_of(a)); + // A double-click on the empty canvas leaves isolation, the object selected. + ev(&mut s, &double, 50.0, 350.0); + let st = s.doc().unwrap(); + assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![a])); + assert!(st.doc.node(b).is_some()); + // Type is not isolated. + let t = id(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Type"})).unwrap()); + assert!(s.execute("object.isolate", &json!({"id": t.0})).is_err()); + s.execute("prefs.set", &json!({"key": "doubleClickToIsolate", "value": false})).unwrap(); + ev(&mut s, &double, 225.0, 125.0); + assert_eq!(s.doc().unwrap().isolation, None, "off: no isolation"); +} + /// General › Use Precise Cursors (#394): the Pen's pointer becomes a crosshair; the Selection /// tool's arrow stays. #[test] diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index f6311cef..949337b3 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -632,7 +632,10 @@ mod tests { let cx2 = cx(&d, &whole, &p); let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0)); assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]); - assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)).is_empty()); + // Off the widgets, the Selection tool's double-click isolates the shape (Double Click To + // Isolate) instead. + let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)); + assert_eq!(a, vec![Action::Exec("object.isolate".into(), json!({"id": id.0}))]); } /// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 3cae0937..b175d72a 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -3,8 +3,8 @@ //! Guides, or with them off View › Snap to Point, snap it), //! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), //! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind, -//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To -//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind), +//! double-click opens the Corners dialog), double-click a group or an object to enter isolation +//! mode (Double Click To Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind), //! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path //! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back //! ([`crate::typewidget`]). @@ -260,12 +260,19 @@ impl Tool for SelectionTool { } if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { let top = h.top_object(cx.isolation); - if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) { - return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; - } if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { return vec![Action::SwitchTool("type".into())]; } + // A group, a compound path or a single path, image, mesh or placed file, not + // the one already isolated. Blends, envelopes, repeats and symbol instances + // aren't: they have editing modes of their own. + use vectorcraft_doc::NodeKind as K; + let isolates = cx.doc.node(top).is_some_and(|n| { + matches!(n.kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_)) + }); + if cx.double_click_isolate && isolates && cx.isolation != Some(top) { + return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; + } } else if cx.isolation.is_some() { return vec![Action::Exec("object.exitIsolation".into(), json!({}))]; } diff --git a/docs/mcp.md b/docs/mcp.md index 36adee92..e39c31f8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1159,7 +1159,7 @@ and `layerRows`, the rows highlighted in the panel. `layer.reverse {ids?}`, `layer.template {ids?, on?}`, `layer.hideOthers` / `layer.showAll`, `layer.outlineOthers` / `layer.previewAll`, `layer.lockOthers` / `layer.unlockAll` (Alt-clicking an eye or a lock runs Hide or Lock Others for the row's layer, or Show or Unlock All when the others already are), `layer.pasteRemembersLayers`, - `object.isolate {id}` / `object.exitIsolation`. + `object.isolate {id}` (a group, a layer or a single object; not type) / `object.exitIsolation`. - **Dialogs and panel state** (control channel and the app's MCP): `ui.layerOptions {ids?}` and `ui.newLayer {sublayer?}` open Layer Options (dialog `layerOptions`: name, color, template, locked, visible, printable, preview, dimImages, dimPercent; OK is one undo step), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`: @@ -1572,7 +1572,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture - `ctrlClickSelectsBehind` (on by default): a Selection tool click with `mods: {cmd: true}` (Command on macOS, Ctrl elsewhere) selects the object under the selected one there, the next such click the one under that, then the topmost again. Cmd held to borrow the selection tool from another tool clicks as usual. -- `doubleClickToIsolate` (on by default): off, a `doubleclick` on a group with the Selection tool no longer isolates it. +- `doubleClickToIsolate` (on by default): a `doubleclick` with the Selection tool isolates a group, a compound path or a + single path, image, mesh or placed file (not type, blends, envelopes, repeats or symbol instances); off, it no longer does. - `usePreciseCursors`: the Pen, Eyedropper, Slice and Blend tools' pointers are a crosshair. - `snapToPointTolerance` (1–8 px, 2 by default): with View › Snap to Point on and Smart Guides off (desktop app), the point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5572264f..1f3f3faa 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -74,7 +74,7 @@ tools, never checked side by side) isn't captured row by row. | Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior | | Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing | | Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing | -| Isolation mode by double-click, breadcrumbs, Esc to exit | done | | +| Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) | | "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion | ## Smart guides and snapping @@ -205,6 +205,7 @@ re-measure of the panels added since. | 2026-10-12 | minor | Attributes: directly selected compound members read and change the compound fill rule; path direction stays member-local (#1197); score unchanged | | 2026-10-12 | minor | Pathfinder Divide: open cutting lines split filled paths with one Undo (#1307); scores and hours unchanged | | 2026-10-12 | minor | Numeric entry: click-to-focus selection waits for release so text drags keep their start (#1326, in part); keyboard focus, double-click, arrows and wheel preserved; scores and hours unchanged; short movements below the click threshold remain open | +| 2026-10-12 | minor | Double Click To Isolate: the Selection tool also isolates a single path, compound path, image, mesh or placed file; only it is hit, marqueed and selected inside; scores and hours unchanged | | 2026-10-11 | minor | A marquee inside a filled shape no longer selects it: only touching its path does (#1103); score unchanged (estimated) | | 2026-10-11 | minor | Live Corners: corners between curved sides round too, the curve trimmed to what the cut leaves (#1144); score unchanged (estimated) | | 2026-10-11 | minor | Artboards: Shift-click selects several, which move, copy and delete together (#1139); score unchanged (estimated) | From ddebe5e479ac3f172ee4ef6918918e43a47cb1be Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 18:46:49 +0700 Subject: [PATCH 09/13] Part of #1377: Rectangular Grid and Polar Grid tool options (skew, frame, compound path, fill) --- ROADMAP.md | 3 +- crates/engine/src/cmd/create.rs | 77 +++++++-- crates/engine/src/tests_draw2.rs | 126 +++++++++++++++ crates/engine/tests/command_sweep.rs | 13 ++ crates/geom/src/shapes.rs | 147 +++++++++++++++--- crates/tools/src/draw2/family.rs | 85 ++++++++-- crates/tools/src/settings.rs | 4 +- crates/ui-egui/src/dialogs/form.rs | 45 ++++-- crates/ui-egui/src/dialogs/shapes.rs | 48 ++++-- crates/ui-egui/src/dialogs/tests.rs | 48 ++++++ .../src/dialogs/tests_rectangular_grid.rs | 80 ++++++++-- crates/ui-egui/src/dialogs/tools.rs | 17 +- crates/ui-egui/src/i18n/cs.tsv | 7 + crates/ui-egui/src/i18n/de.tsv | 7 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 7 + crates/ui-egui/src/i18n/hu.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/ko.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + docs/target-app-parity.md | 5 +- docs/ui-parity.md | 4 +- 27 files changed, 700 insertions(+), 93 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5f70d2d0..43eddc80 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). | | 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. | | 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. | | 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undo step (#1331). EPS import no longer hangs on shading functions fanned out through shared arrays (#1330), looped huge arcs, fine dashes and rectangle arrays (#1329), or very long dash patterns (#1332). Plug-in whole-number fields keep values past 2^53 exactly as typed (#1327). Large documents use far less memory: decoded images are shared and capped, undo history is bounded by memory, native files open in the background and save as a stream, and Data Recovery writes images once (#1245). Opacity mask commands (#1336), Simplify and Add Anchor Points (#1337) add no undo step when they change nothing. EPS files with thousands of tiling patterns, spot inks or clipped shadings open several times faster with the same result (#1341). A `.ai` or PDF saved from a CMYK document with transparency no longer opens blank in applications that read soft masks by luminosity (#1359, part of #1286). Gradient-filled shapes in a PDF or `.ai` open as one path with the gradient instead of a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on their outlines (#1351). A `.ai` saved with a bleed opens on its artboard with the bleed round it as the document's bleed, from its layers, and Crop To › Trim does the same for a PDF (#1349); a PDF saved with its editing data opens from it, with its layers, groups and art past the page, as a `.ai` does (#1345). Art moved into a hidden or locked layer (row drag, Merge Selected, Flatten Artwork, Collect in New Layer) or hidden by a data set is deselected (#1355), and hiding or locking a layer with a large selection deselects its objects in linear time (200,000 objects: 3.9 s to 24 ms) (#1353). Objects moved whole together with direct-selected anchors follow Preferences › Transform Pattern Tiles (#1354). Preferences › Appearance of Black › On Screen works: Display All Blacks as Rich Black shows 100K black as RGB black on the canvas (#1350). DXF: a closed control-point SPLINE opens as a closed path (#1318). Numeric fields: dragging over part of a value in an unfocused field selects that part instead of the whole value (#1339). Pathfinder Divide: an open line or curve across a filled shape cuts it into separate pieces (#1335); Unite, Minus Front, Minus Back, Intersect and Exclude results keep the source object's name, so exported SVG keeps its id (#1308). Object › Align follows the Align To choice (one object aligns to the artboard), and the choice is kept across restarts (#1347). Artboard tool: a click on an object creates an artboard fitted to it (#1252). Rulers: drag the ruler corner to move the zero point, double-click it to reset; the X/Y fields measure from it (#1250). Live Corners: with Direct Selection, a click on a corner widget selects that corner, so the next drag rounds it alone (#1239). A click with the Polar Grid tool opens a dialog for its size and divider counts, as the Rectangular Grid tool does (#1192). A `.ai` whose editing data holds an image on hidden lines is no longer misread (#1364, part of #1264). PDF export leaves out art placed beyond 1,000,000,000 pt instead of aborting debug and test builds (#1360). Affinity import: rectangles keep per-corner radii and straight (cut) corners (#1369, part of #666). Tools panel: the tool buttons are centred inside the panel border, in one and two columns (#1363). Spanish: 371 more interface strings translated (presets, shortcut editor, print and PDF options, Save for Web and more) (#1371). The Properties panel no longer widens and cuts off its labels when an Image Trace object is selected (#1374, part of #1358). | diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 8f07dd2a..01a25a66 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -33,8 +33,24 @@ pub fn specs() -> Vec { cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line), cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral), cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc), - cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid), - cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid), + cmd!( + "shape.rectangularGrid", + "Rectangular Grid", + [], + None, + "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}", + has_doc, + rect_grid + ), + cmd!( + "shape.polarGrid", + "Polar Grid", + [], + None, + "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}", + has_doc, + polar_grid + ), cmd!( "path.create", "Create Path", @@ -351,14 +367,24 @@ fn arc(s: &mut Session, p: &Value) -> Result { add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None) } -fn grid_group(s: &mut Session, label: &str, paths: Vec) -> Result { +/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the +/// current fill when `fill`; a path of several subpaths becomes a compound path of them. +fn grid_group(s: &mut Session, label: &str, paths: Vec, fill: bool) -> Result { let parent = s.doc()?.target_parent()?; - let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width); + let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None }; + let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width); let id = s.edit(label, |d, sel| { let children = paths .into_iter() .map(|pd| { - let mut n = Node::path(d.alloc_id(), pd, Appearance::default()); + let mut n = if pd.subpaths.len() > 1 { + let members = + pd.subpaths.into_iter().map(|sp| std::sync::Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))); + let children = members.collect(); + Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::NonZero }) + } else { + Node::path(d.alloc_id(), pd, Appearance::default()) + }; look.apply(d, &mut n); std::sync::Arc::new(n) }) @@ -371,18 +397,43 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec) -> Result Result<(u32, f64)> { + let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32; + let skew = match p.get(key) { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?, + }; + Ok((n, skew.clamp(-shapes::GRID_SKEW_MAX, shapes::GRID_SKEW_MAX))) +} + +/// A grid's on/off option `key` (`default` when absent). +fn switch(p: &Value, key: &str, default: bool, cmd: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))), + } +} + fn rect_grid(s: &mut Session, p: &Value) -> Result { - let r = rect_of(p, "shape.rectangularGrid")?; - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32; - let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32; - grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true)) + const CMD: &str = "shape.rectangularGrid"; + let r = rect_of(p, CMD)?; + let paths = + shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?); + grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?) } fn polar_grid(s: &mut Session, p: &Value) -> Result { - let r = rect_of(p, "shape.polarGrid")?; - let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32; - let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32; - grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad)) + const CMD: &str = "shape.polarGrid"; + let r = rect_of(p, CMD)?; + let paths = shapes::polar_grid( + r, + dividers(p, "concentric", "concentricSkew", CMD)?, + dividers(p, "radial", "radialSkew", CMD)?, + switch(p, "compound", false, CMD)?, + ); + grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?) } /// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 004c9d1d..f31d422b 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -442,6 +442,132 @@ fn line_family_tools_draw_and_click_asks_dialog() { assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]); } +/// The children of the grid a `shape.rectangularGrid` / `shape.polarGrid` call made, bottom first. +fn grid(s: &mut Session, cmd: &str, p: serde_json::Value) -> Vec> { + let id = NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap()); + s.doc().unwrap().doc.node(id).unwrap().children().unwrap().to_vec() +} + +/// `base` with the keys of `extra` added. +fn with(base: &serde_json::Value, extra: serde_json::Value) -> serde_json::Value { + let mut p = base.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p +} + +fn is_filled(n: &vectorcraft_doc::Node) -> bool { + n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None) +} + +/// #1377: the Rectangular Grid options. By default the frame is one rectangle under evenly spaced +/// dividers and nothing is filled; `frame: false` draws its four sides instead, `fill` paints the +/// current fill and the skews crowd the dividers to the top / right (positive) or bottom / left. +#[test] +fn rectangular_grid_options_frame_fill_and_skew() { + const CMD: &str = "shape.rectangularGrid"; + let mut s = session(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "rows": 3, "columns": 3}); + // The y of the three horizontal dividers and the x of the three vertical ones (the last six children). + let dividers = |g: &[std::sync::Arc]| { + let starts: Vec = g[g.len() - 6..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect(); + starts[..3].iter().map(|p| p.y).chain(starts[3..].iter().map(|p| p.x)).collect::>() + }; + let plain = grid(&mut s, CMD, base.clone()); + assert_eq!(plain.len(), 7); + assert!(plain[0].path_data().unwrap().is_closed(), "the frame lies under the dividers"); + assert!(plain.iter().all(|n| !is_filled(n) && n.appearance.stroke().is_some())); + assert_eq!(dividers(&plain), [25.0, 50.0, 75.0, 25.0, 50.0, 75.0]); + // No frame: its four sides are lines of their own. + let sides = grid(&mut s, CMD, with(&base, json!({"frame": false}))); + assert_eq!(sides.len(), 10); + assert!(sides.iter().all(|n| !n.path_data().unwrap().is_closed() && n.path_data().unwrap().anchor_count() == 2)); + assert_eq!(dividers(&sides), dividers(&plain)); + let filled = grid(&mut s, CMD, with(&base, json!({"fill": true}))); + assert!(filled.iter().all(|n| is_filled(n)), "Fill Grid paints the current fill"); + assert_eq!(filled[0].appearance.fill().unwrap().paint, s.paint.fill); + // Skew: toward the top and the right when positive, the bottom and the left when negative; + // past ±500 % is ±500 %. + let top_right = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 100, "columnSkew": 100})))); + for (got, want) in top_right.iter().zip([6.25, 25.0, 56.25, 43.75, 75.0, 93.75]) { + assert!((got - want).abs() < 1e-9, "{top_right:?}"); + } + let bottom_left = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": -100, "columnSkew": -100})))); + assert!(bottom_left[1] > 50.0 && bottom_left[4] < 50.0, "{bottom_left:?}"); + let most = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 500, "columnSkew": -500})))); + let beyond = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 1e300, "columnSkew": -1e300})))); + assert_eq!(most, beyond); + assert!(most.iter().all(|v| *v > 0.0 && *v < 100.0), "{most:?}"); +} + +/// #1377: the Polar Grid options. The ellipses lie under the radial dividers, largest first; +/// `compound` pairs them into compound-path rings, `fill` paints the current fill and the skews +/// crowd the ellipses outward and the radial dividers clockwise of the top one (positive). +#[test] +fn polar_grid_options_compound_fill_and_skew() { + const CMD: &str = "shape.polarGrid"; + let mut s = session(); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "concentric": 3, "radial": 4}); + let width = |n: &vectorcraft_doc::Node| n.geometric_bounds().unwrap().width(); + let tips = + |g: &[std::sync::Arc]| g[4..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p).collect::>(); + let plain = grid(&mut s, CMD, base.clone()); + assert_eq!(plain.len(), 8); + assert_eq!(plain[..4].iter().map(|n| width(n).round()).collect::>(), [100.0, 75.0, 50.0, 25.0], "largest at the bottom"); + assert!(plain[..4].iter().all(|n| n.path_data().unwrap().is_closed()) && plain[4..].iter().all(|n| !n.path_data().unwrap().is_closed())); + assert!(plain.iter().all(|n| !is_filled(n))); + assert!(grid(&mut s, CMD, with(&base, json!({"fill": true}))).iter().all(|n| is_filled(n))); + // Compound: 4 ellipses make 2 rings, each a compound path of an outside and a hole running + // against it that carries the grid's paint itself; with 5, the innermost ellipse stays a path. + let rings = grid(&mut s, CMD, with(&base, json!({"compound": true, "fill": true}))); + assert_eq!(rings.len(), 6); + for (ring, sizes) in rings[..2].iter().zip([[100.0, 75.0], [50.0, 25.0]]) { + let NodeKind::Compound { children, rule } = &ring.kind else { panic!("{:?}", ring.kind) }; + assert_eq!(*rule, FillRule::NonZero); + assert_eq!(children.iter().map(|n| width(n).round()).collect::>(), sizes); + let areas: Vec = children.iter().map(|n| n.path_data().unwrap().subpaths[0].signed_area()).collect(); + assert!(areas[0] * areas[1] < 0.0, "the hole runs against the outside: {areas:?}"); + assert!(is_filled(ring) && ring.appearance.stroke().is_some()); + } + let five = grid(&mut s, CMD, with(&base, json!({"compound": true, "concentric": 4, "radial": 0}))); + assert_eq!(five.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::>(), [true, true, false]); + // Skew. + let out = grid(&mut s, CMD, with(&base, json!({"concentricSkew": 100, "radialSkew": 500}))); + for (n, want) in out[..4].iter().zip([100.0, 93.75, 75.0, 43.75]) { + assert!((width(n) - want).abs() < 1e-6, "{}", width(n)); + } + assert!(tips(&out)[0].distance(Point::new(50.0, 0.0)) < 1e-9, "the top divider stays"); + assert!(tips(&out)[1..].iter().all(|t| t.x > 50.0), "clockwise of the top: {:?}", tips(&out)); + let inward = grid(&mut s, CMD, with(&base, json!({"concentricSkew": -100, "radialSkew": -500}))); + assert!((width(&inward[1]) - 56.25).abs() < 1e-6); + assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward)); +} + +/// The grid options are checked: a skew is a number and a switch true or false, or the command +/// fails without an undo step. +#[test] +fn grid_commands_reject_options_of_the_wrong_type() { + let mut s = session(); + let size = json!({"x": 0, "y": 0, "width": 10, "height": 10}); + for (cmd, key, value) in [ + ("shape.rectangularGrid", "rowSkew", json!("50")), + ("shape.rectangularGrid", "columnSkew", json!(true)), + ("shape.rectangularGrid", "frame", json!(1)), + ("shape.rectangularGrid", "fill", json!("yes")), + ("shape.polarGrid", "concentricSkew", json!([1])), + ("shape.polarGrid", "radialSkew", json!({})), + ("shape.polarGrid", "compound", json!(0)), + ("shape.polarGrid", "fill", json!("true")), + ] { + let before = s.doc().unwrap().history.undo.len(); + let p = with(&size, json!({key: value})); + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step"); + assert!(s.execute(cmd, &with(&size, json!({key: null}))).is_ok(), "{cmd}: null is the default"); + } +} + #[test] fn draw2_commands_reject_bad_params() { let mut s = session(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index fcf1138b..3d635e0d 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -325,6 +325,19 @@ fn structured_junk() { ("object.path.offsetPath", json!({"offset": 1e308})), ("object.path.offsetPath", json!({"offset": -1e308})), ("object.path.simplify", json!({"tolerance": -1, "cornerAngle": 1e308})), + ( + "shape.rectangularGrid", + json!({"x": 0, "y": 0, "width": 1e308, "height": -1e308, "rows": 999, "columns": u64::MAX, "rowSkew": 1e308, "columnSkew": -1e308, "frame": false, "fill": true}), + ), + ("shape.rectangularGrid", json!({"x": 0, "y": 0, "width": 0, "height": 0, "rowSkew": "x", "frame": 3})), + ( + "shape.polarGrid", + json!({"x": 0, "y": 0, "width": 1e308, "height": 1e-308, "concentric": 999, "radial": 999, "concentricSkew": -1e308, "radialSkew": 1e308, "compound": true, "fill": true}), + ), + ( + "shape.polarGrid", + json!({"x": 0, "y": 0, "width": 10, "height": 10, "concentric": -1, "radial": 0.5, "radialSkew": null, "compound": "yes"}), + ), ("object.path.splitIntoGrid", json!({"rows": 500, "columns": 1, "gutter": 1e308})), ("object.path.splitIntoGrid", json!({"rows": 0.4})), ("object.setBounds", json!({"width": 0, "height": 0})), diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index d123fc4e..5aa16b63 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -208,36 +208,70 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo PathData::single(sp) } -/// Rectangular grid as separate open lines (plus an optional frame rectangle). -pub fn rectangular_grid(r: Rect, h_dividers: u32, v_dividers: u32, frame: bool) -> Vec { +/// The largest grid divider skew, in percent either way. +pub const GRID_SKEW_MAX: f64 = 500.0; + +/// Where a divider at fraction `t` (0..1) of a grid's extent lands under `skew` percent: evenly +/// spaced at 0, crowded toward 1 by a positive skew and toward 0 by a negative one (`t` raised to +/// 1 + |skew| / 100 from the far end). +fn skewed(t: f64, skew: f64) -> f64 { + // NaN reads as no skew. + let k = 1.0 + skew.abs().min(GRID_SKEW_MAX) / 100.0; + if skew > 0.0 { + 1.0 - (1.0 - t).powf(k) + } else if skew < 0.0 { + t.powf(k) + } else { + t + } +} + +/// Rectangular grid, bottom object first: the outside as one rectangle (`frame`) or as its four +/// sides (top, bottom, left, right), then the horizontal and the vertical dividers as open lines. +/// A positive `h_skew` crowds the horizontal dividers toward the top and a positive `v_skew` the +/// vertical ones toward the right (percent, see [`GRID_SKEW_MAX`]). +pub fn rectangular_grid(r: Rect, (h_dividers, h_skew): (u32, f64), (v_dividers, v_skew): (u32, f64), frame: bool) -> Vec { let r = r.abs(); - let mut out = Vec::new(); + let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); + let mut out = if frame { vec![rectangle(r)] } else { vec![line(tl, tr), line(bl, br), line(tl, bl), line(tr, br)] }; for i in 1..=h_dividers { - let y = r.y0 + r.height() * i as f64 / (h_dividers + 1) as f64; + // From the top down: toward the top is toward 0. + let y = r.y0 + r.height() * skewed(i as f64 / (h_dividers + 1) as f64, -h_skew); out.push(line(Point::new(r.x0, y), Point::new(r.x1, y))); } for i in 1..=v_dividers { - let x = r.x0 + r.width() * i as f64 / (v_dividers + 1) as f64; + let x = r.x0 + r.width() * skewed(i as f64 / (v_dividers + 1) as f64, v_skew); out.push(line(Point::new(x, r.y0), Point::new(x, r.y1))); } - if frame { - out.push(rectangle(r)); - } out } -/// Polar grid: concentric ellipses plus radial dividers. -pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec { +/// Polar grid, bottom object first: the concentric ellipses from the outside in, then the radial +/// dividers from the top one clockwise. A positive `c_skew` crowds the ellipses toward the outside +/// and a positive `r_skew` the radial dividers clockwise of the top one (percent, see +/// [`GRID_SKEW_MAX`]). With `compound`, the ellipses pair up from the outside in into rings (two +/// subpaths, the inner one reversed so that it is a hole under either fill rule); an odd one out +/// is the innermost ellipse alone. +pub fn polar_grid(r: Rect, (concentric, c_skew): (u32, f64), (radial, r_skew): (u32, f64), compound: bool) -> Vec { let r = r.abs(); let c = r.center(); - let mut out = Vec::new(); - for i in 1..=concentric + 1 { - let f = i as f64 / (concentric + 1) as f64; - out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f)))); + let mut out: Vec = Vec::new(); + for i in (1..=concentric + 1).rev() { + let f = skewed(i as f64 / (concentric + 1) as f64, c_skew); + let mut e = ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))); + // Every second ellipse from the outside is the hole of the one before it. + match out.last_mut().filter(|_| compound && (concentric + 1 - i) % 2 == 1) { + Some(ring) => { + e.reverse(); + ring.subpaths.append(&mut e.subpaths); + } + None => out.push(e), + } } // No dividers at 0 (the loop never divides by it). for i in 0..radial { - let a = -PI / 2.0 + TAU * i as f64 / radial as f64; + // The turn runs clockwise on screen: clockwise of the top is toward its start. + let a = -PI / 2.0 + TAU * skewed(i as f64 / radial as f64, -r_skew); out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin()))); } out @@ -401,11 +435,88 @@ mod tests { #[test] fn grids() { - assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11); - assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11); + let r = Rect::new(0.0, 0.0, 10.0, 10.0); + assert_eq!(rectangular_grid(r, (5, 0.0), (5, 0.0), true).len(), 11); + assert_eq!(polar_grid(r, (5, 0.0), (5, 0.0), false).len(), 11); // #1008: no radial dividers is none, only the outer ellipse without concentric ones. for (radial, len) in [(0, 1), (1, 2), (2, 3)] { - assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}"); + assert_eq!(polar_grid(r, (0, 0.0), (radial, 0.0), false).len(), len, "{radial}"); + } + } + + fn close(got: &[f64], want: &[f64]) -> bool { + got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9) + } + + /// The first anchor of each of `paths`. + fn starts(paths: &[PathData]) -> Vec { + paths.iter().map(|p| p.subpaths[0].anchors[0].p).collect() + } + + /// #1377: the frame is one rectangle or its four sides, at the bottom; a skew moves the + /// dividers toward the top / right (positive) or the bottom / left, in order and inside the + /// grid, and none leaves them evenly spaced. + #[test] + fn a_rectangular_grid_frames_and_skews_its_dividers() { + let r = Rect::new(0.0, 0.0, 100.0, 100.0); + let framed = rectangular_grid(r, (3, 0.0), (1, 0.0), true); + assert!(framed[0].is_closed() && framed[1..].iter().all(|p| !p.is_closed())); + assert_eq!(starts(&framed[1..]), [Point::new(0.0, 25.0), Point::new(0.0, 50.0), Point::new(0.0, 75.0), Point::new(50.0, 0.0)]); + let sides = rectangular_grid(r, (3, 0.0), (1, 0.0), false); + assert_eq!(sides.len(), 8); + assert!(sides.iter().all(|p| !p.is_closed() && p.anchor_count() == 2)); + assert_eq!(sides[4..], framed[1..]); + for (skew, toward_start) in [(100.0, true), (-100.0, false), (GRID_SKEW_MAX, true), (-1e9, false), (f64::INFINITY, true)] { + let g = rectangular_grid(r, (3, skew), (3, -skew), true); + let ys: Vec = starts(&g[1..4]).iter().map(|p| p.y).collect(); + let xs: Vec = starts(&g[4..]).iter().map(|p| p.x).collect(); + for v in [&ys, &xs] { + assert!(v.windows(2).all(|w| w[0] < w[1]) && v[0] > 0.0 && v[2] < 100.0, "{skew}: {v:?}"); + // Toward the top (y) and the left (x) is toward 0: the middle divider passes the middle. + assert_eq!(v[1] < 50.0, toward_start, "{skew}: {v:?}"); + } + assert_eq!(ys, xs, "one curve for both axes"); + } + // 100 % squares the fraction from the side the dividers crowd to: 1/4, 1/2 and 3/4 from the top. + let g = rectangular_grid(r, (3, 100.0), (0, 0.0), true); + assert!(close(&starts(&g[1..]).iter().map(|p| p.y).collect::>(), &[6.25, 25.0, 56.25]), "{g:?}"); + assert_eq!(rectangular_grid(r, (3, f64::NAN), (1, f64::NAN), true), framed); + } + + /// #1377: the ellipses come from the outside in and skew toward the outside (positive) or the + /// centre; the radial dividers keep the top one and skew clockwise (positive) of it or + /// counterclockwise; as compound paths the ellipses pair up into rings. + #[test] + fn a_polar_grid_skews_its_dividers_and_pairs_ellipses_into_rings() { + let r = Rect::new(0.0, 0.0, 100.0, 100.0); + let widths = |g: &[PathData], n: usize| g[..n].iter().map(|p| p.bounds().unwrap().width()).collect::>(); + for (skew, want) in [(0.0, [100.0, 75.0, 50.0, 25.0]), (100.0, [100.0, 93.75, 75.0, 43.75]), (-100.0, [100.0, 56.25, 25.0, 6.25])] { + let got = widths(&polar_grid(r, (3, skew), (0, 0.0), false), 4); + assert!(close(&got, &want), "{skew}: {got:?}"); + } + // Radial: four dividers point up, right, down and left; skewed, the first stays up and the + // other three all point to the right (clockwise of it) or to the left. + let ends = |skew: f64| polar_grid(r, (0, 0.0), (4, skew), false)[1..].iter().map(|p| p.subpaths[0].anchors[1].p).collect::>(); + let even = ends(0.0); + for (p, want) in even.iter().zip([(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]) { + assert!(p.distance(Point::new(want.0, want.1)) < 1e-9, "{even:?}"); + } + for (skew, right) in [(GRID_SKEW_MAX, true), (-GRID_SKEW_MAX, false)] { + let e = ends(skew); + assert!(e[0].distance(even[0]) < 1e-9); + assert!(e[1..].iter().all(|p| (p.x > 50.0) == right && (p.x - 50.0).abs() > 1e-6), "{skew}: {e:?}"); + } + // Compound: 4 ellipses are 2 rings, 5 are 2 rings and the innermost ellipse; each ring's + // hole runs against its outside. + for (concentric, shapes) in [(3, vec![2, 2]), (4, vec![2, 2, 1]), (0, vec![1])] { + let g = polar_grid(r, (concentric, 0.0), (2, 0.0), true); + assert_eq!(g.len(), shapes.len() + 2); + assert_eq!(g[..shapes.len()].iter().map(|p| p.subpaths.len()).collect::>(), shapes); + for ring in g.iter().filter(|p| p.subpaths.len() == 2) { + assert!(ring.is_closed()); + assert!(ring.subpaths[0].signed_area() * ring.subpaths[1].signed_area() < 0.0); + assert!(ring.subpaths[0].signed_area().abs() > ring.subpaths[1].signed_area().abs()); + } } } diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 5118fa93..9b068bc0 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -27,6 +27,14 @@ pub struct FamilyTool { pub rows: u32, /// Columns / radial dividers. pub columns: u32, + /// The grids' divider skews in percent: rows / concentric, then columns / radial. + pub skew: [f64; 2], + /// Rectangular Grid: the outside is one rectangle (else its four sides). + pub frame: bool, + /// Polar Grid: the ellipses pair up into compound paths. + pub compound: bool, + /// The grids take the current fill. + pub fill: bool, snap: DrawSnap, } @@ -50,6 +58,10 @@ impl FamilyTool { clockwise: true, rows: 5, columns: 5, + skew: [0.0; 2], + frame: true, + compound: false, + fill: false, snap: DrawSnap::default(), } } @@ -79,19 +91,14 @@ impl FamilyTool { json!({"cx": start.x, "cy": start.y, "radius": r, "decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}), ) } - "rectangularGrid" => { + // The grids: the dragged box with the tool's options. + "rectangularGrid" | "polarGrid" => { let r = drag_rect(start, p, m); - ( - "shape.rectangularGrid".into(), - json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}), - ) - } - "polarGrid" => { - let r = drag_rect(start, p, m); - ( - "shape.polarGrid".into(), - json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}), - ) + let mut params = self.options(); + if let Some(o) = params.as_object_mut() { + o.extend([("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())].map(|(k, v)| (k.to_string(), json!(v)))); + } + (format!("shape.{}", self.id), params) } _ => { let d = if m.shift { square(p - start) } else { p - start }; @@ -206,8 +213,12 @@ impl Tool for FamilyTool { fn options(&self) -> Value { match self.id { "spiral" => json!({"decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}), - "rectangularGrid" => json!({"rows": self.rows, "columns": self.columns}), - "polarGrid" => json!({"concentric": self.rows, "radial": self.columns}), + "rectangularGrid" => json!({ + "rows": self.rows, "columns": self.columns, "rowSkew": self.skew[0], "columnSkew": self.skew[1], "frame": self.frame, "fill": self.fill, + }), + "polarGrid" => json!({ + "concentric": self.rows, "radial": self.columns, "concentricSkew": self.skew[0], "radialSkew": self.skew[1], "compound": self.compound, "fill": self.fill, + }), _ => json!({"closed": self.closed}), } } @@ -219,11 +230,22 @@ impl Tool for FamilyTool { "clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise), "rows" | "concentric" => self.rows = v.as_u64().unwrap_or(5).min(999) as u32, "columns" | "radial" => self.columns = v.as_u64().unwrap_or(5).min(999) as u32, + "rowSkew" | "concentricSkew" => self.skew[0] = grid_skew(v, self.skew[0]), + "columnSkew" | "radialSkew" => self.skew[1] = grid_skew(v, self.skew[1]), + "frame" => self.frame = v.as_bool().unwrap_or(self.frame), + "compound" => self.compound = v.as_bool().unwrap_or(self.compound), + "fill" => self.fill = v.as_bool().unwrap_or(self.fill), _ => {} } } } +/// A grid divider skew from option value `v`: a finite number within the range, else `current`. +fn grid_skew(v: &Value, current: f64) -> f64 { + let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; + v.as_f64().filter(|s| s.is_finite()).map_or(current, |s| s.clamp(-max, max)) +} + #[cfg(test)] mod tests { use super::*; @@ -243,7 +265,10 @@ mod tests { assert_eq!(a[0], Action::Begin("Rectangular Grid".into())); assert_eq!( a[1], - Action::Preview("shape.rectangularGrid".into(), json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5})) + Action::Preview( + "shape.rectangularGrid".into(), + json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false}) + ) ); let a = t.key(&cx, ToolKey::Right, Mods::default()); assert!(matches!(&a[0], Action::Preview(_, v) if v["columns"] == 6)); @@ -256,6 +281,36 @@ mod tests { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 60.0)), vec![Action::Commit]); } + /// #1377: a drag draws with the tool's stored options, which keep to their types and range. + #[test] + fn a_grid_drag_uses_the_stored_options() { + let mut t = FamilyTool::new("polarGrid"); + for (key, value) in [ + ("concentric", json!(2)), + ("concentricSkew", json!(-40)), + ("radialSkew", json!(9000)), + ("compound", json!(true)), + ("fill", json!(true)), + // Not this tool's, and not values: ignored. + ("frame", json!("no")), + ("concentricSkew", json!("far")), + ("fill", json!(null)), + ] { + t.set_option(key, &value); + } + let options = json!({"concentric": 2, "radial": 5, "concentricSkew": -40.0, "radialSkew": 500.0, "compound": true, "fill": true}); + assert_eq!(t.options(), options); + let (command, params) = t.command(Point::new(10.0, 20.0), Point::new(40.0, 80.0), Mods::default()); + assert_eq!(command, "shape.polarGrid"); + let mut want = options; + want.as_object_mut().unwrap().extend(json!({"x": 10.0, "y": 20.0, "width": 30.0, "height": 60.0}).as_object().unwrap().clone()); + assert_eq!(params, want); + let mut t = FamilyTool::new("rectangularGrid"); + t.set_option("frame", &json!(false)); + t.set_option("rowSkew", &json!(12.5)); + assert_eq!(t.options(), json!({"rows": 5, "columns": 5, "rowSkew": 12.5, "columnSkew": 0.0, "frame": false, "fill": false})); + } + #[test] fn click_opens_dialog_and_arc_shift() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 39471804..9f1301c6 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -27,8 +27,8 @@ const ROWS: &[Row] = &[ (&["puppetWarp"], None, &["showMesh", "expand"]), (&["arc"], None, &["closed"]), (&["spiral"], None, &["decay", "segments", "clockwise"]), - (&["rectangularGrid"], None, &["rows", "columns"]), - (&["polarGrid"], None, &["concentric", "radial"]), + (&["rectangularGrid"], None, &["rows", "columns", "rowSkew", "columnSkew", "frame", "fill"]), + (&["polarGrid"], None, &["concentric", "radial", "concentricSkew", "radialSkew", "compound", "fill"]), (&["pencil", "paintbrush"], None, &["fidelity", "fill", "editWithin", "closeWithin"]), (&["smooth"], None, &["fidelity"]), (&["blobBrush", "eraser"], None, &["size"]), diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index fd14ff40..99d0164a 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -67,13 +67,25 @@ fn order(kind: &str) -> &'static [&'static str] { match kind { "offsetPath" => &["offset", "joins", "miterLimit"], "splitIntoGrid" => &["rows", "columns", "gutter"], - "rectangularGrid" => &["width", "height", "rows", "columns"], - "polarGrid" => &["width", "height", "concentric", "radial"], + "rectangularGrid" => &["width", "height", "rows", "rowSkew", "columns", "columnSkew"], + "polarGrid" => &["width", "height", "concentric", "concentricSkew", "radial", "radialSkew"], "artboardOptions" => &["name", "width", "height"], _ => &[], } } +/// The form dialog ([`grid`]) fields that are a grid tool's divider skew, a slider in percent: the +/// words at the two ends of its rail (where the dividers crowd to), in the UI language. +fn skew_ends(kind: &str, key: &str) -> Option<(&'static str, &'static str)> { + match (kind, key) { + ("rectangularGrid", "rowSkew") => Some((tl!("Bottom"), tl!("Top"))), + // The radial dividers gather on that side of the top one. + ("rectangularGrid", "columnSkew") | ("polarGrid", "radialSkew") => Some((tl!("Left"), tl!("Right"))), + ("polarGrid", "concentricSkew") => Some((tl!("Inside"), tl!("Outside"))), + _ => None, + } +} + /// The choices of a form dialog's ([`grid`]) dropdown field, as (value, label in the UI /// language); empty for a field typed as text. fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> { @@ -163,8 +175,10 @@ fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: & } /// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys -/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns. +/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns and the +/// [`skew_ends`] fields as sliders. pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + let rail = Tokens::get(ui.ctx()).input_border; let (lengths, first) = (lengths(&d.kind), order(&d.kind)); let mut keys: Vec = d .fields @@ -179,6 +193,14 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { let options = choices(&d.kind, &k); if lengths.contains(&k.as_str()) { length_field(ui, d, &k, &humanized(&k), unit); + } else if let Some(ends) = skew_ends(&d.kind, &k) { + let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; + row_label(ui, tl!("Skew:")); + ui.vertical(|ui| { + ui.horizontal(|ui| slider_field(ui, d, &k, -max..=max, "%", &|_| rail)); + rail_ends(ui, ends); + }); + ui.end_row(); } else if options.is_empty() { let label = match (d.kind.as_str(), k.as_str()) { ("rectangularGrid", "rows") => tl!("Horizontal Dividers:").into(), @@ -465,17 +487,22 @@ pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: s /// The words at the two ends of a slider's rail (Less … More), under a slider whose label column /// is `label_w` wide. -pub(super) fn slider_ends(ui: &mut egui::Ui, label_w: f32, (left, right): (&str, &str)) { - let t = Tokens::get(ui.ctx()); +pub(super) fn slider_ends(ui: &mut egui::Ui, label_w: f32, ends: (&str, &str)) { ui.horizontal(|ui| { ui.add_space(label_w + ui.spacing().item_spacing.x); - let (r, _) = ui.allocate_exact_size(egui::vec2(SLIDER_WIDTH, 14.0), egui::Sense::hover()); - let font = egui::FontId::proportional(11.0); - ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, left, font.clone(), t.text_dim); - ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, right, font, t.text_dim); + rail_ends(ui, ends); }); } +/// The words of [`slider_ends`] under a rail that starts where the next widget does. +fn rail_ends(ui: &mut egui::Ui, (left, right): (&str, &str)) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(egui::vec2(SLIDER_WIDTH, 14.0), egui::Sense::hover()); + let font = egui::FontId::proportional(11.0); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, left, font.clone(), t.text_dim); + ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, right, font, t.text_dim); +} + /// The field where [`preview`] keeps the parameters it last previewed. pub(super) const PREVIEWED: &str = "__previewed"; diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index ac479732..9df9280c 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -1,13 +1,13 @@ //! Shape size dialogs (a click with a shape tool): Rectangle, Rounded Rectangle, Ellipse, Polygon, -//! Star and Line Segment Tool Options. +//! Star, Line Segment Tool Options and the two grids (size, divider counts and skews, switches). use serde_json::{Value, json}; -use super::{DialogSpec, run_and_close}; +use super::{DialogSpec, form, run_and_close}; use crate::VectorcraftApp; use crate::state::Dialog; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, ..DialogSpec::FORM }; fn title(kind: &str) -> &'static str { match kind { @@ -22,6 +22,26 @@ fn title(kind: &str) -> &'static str { } } +/// The grids' divider counts and skews: (rows / concentric, columns / radial). +fn grid_keys(kind: &str) -> ([&'static str; 2], [&'static str; 2]) { + if kind == "polarGrid" { (["concentric", "radial"], ["concentricSkew", "radialSkew"]) } else { (["rows", "columns"], ["rowSkew", "columnSkew"]) } +} + +/// The form's fields, then a grid's switches. +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + form::grid(ui, d, app.session.general_unit()); + let own = match d.kind.as_str() { + "rectangularGrid" => ("frame", tl!("Use Outside Rectangle As Frame")), + "polarGrid" => ("compound", tl!("Create Compound Path From Ellipses")), + _ => return false, + }; + ui.add_space(8.0); + for (key, label) in [own, ("fill", tl!("Fill Grid"))] { + form::check(ui, d, key, label); + } + false +} + fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); let (id, params) = match d.kind.as_str() { @@ -31,16 +51,18 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { "shape.star", json!({"cx": x, "cy": y, "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64}), ), - "rectangularGrid" => ( - "shape.rectangularGrid", - json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), - "rows": (d.f64("rows", 5.0) as u64).min(999), "columns": (d.f64("columns", 5.0) as u64).min(999)}), - ), - "polarGrid" => ( - "shape.polarGrid", - json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), - "concentric": (d.f64("concentric", 5.0) as u64).min(999), "radial": (d.f64("radial", 5.0) as u64).min(999)}), - ), + kind @ ("rectangularGrid" | "polarGrid") => { + // The switches as they are; the typed fields as numbers. + let (mut p, (counts, skews)) = (form::params(d), grid_keys(kind)); + for (k, default) in [("width", 100.0), ("height", 100.0)] { + p[k] = json!(d.f64(k, default)); + } + for (count, skew) in counts.into_iter().zip(skews) { + p[count] = json!((d.f64(count, 5.0) as u64).min(999)); + p[skew] = json!(d.f64(skew, 0.0)); + } + (if kind == "polarGrid" { "shape.polarGrid" } else { "shape.rectangularGrid" }, p) + } "lineSegment" => { let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians()); ("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()})) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 99465e77..36bc7ae4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -729,6 +729,54 @@ fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() { assert_eq!(app.session.doc().unwrap().doc, made); } +/// #1377: Polar Grid's dialog has a skew slider under each divider count, Create Compound Path +/// From Ellipses and Fill Grid, opened with the tool's stored values; OK draws with them and +/// stores them. +#[test] +fn polar_grid_dialog_edits_skews_compound_and_fill() { + use vectorcraft_doc::{NodeId, NodeKind}; + let mut app = app(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 3, "radial": 4, "radialSkew": 500}})).unwrap(); + open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20})); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + let labels = [ + "Concentric Dividers:", + "Skew:", + "Inside", + "Outside", + "Radial Dividers:", + "Skew:", + "Left", + "Right", + "Create Compound Path From Ellipses", + "Fill Grid", + ]; + let mut from = 0; + for label in labels { + from += text[from..].find(&format!("{label}\n")).unwrap_or_else(|| panic!("no {label:?} in order in {text}")) + label.len(); + } + assert!(!text.contains("Use Outside Rectangle As Frame"), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.fields["concentricSkew"].clone(), d.fields["radialSkew"].clone()), (json!(0.0), json!(500.0))); + assert_eq!((d.fields["compound"].clone(), d.fields["fill"].clone()), (json!(false), json!(false))); + for (key, value) in [("concentricSkew", json!(100)), ("compound", json!(true)), ("fill", json!(true))] { + d.fields.insert(key.into(), value); + } + let result = confirm(&mut app).unwrap(); + let s = app.session.doc().unwrap(); + let children = s.doc.node(NodeId(result["id"].as_u64().unwrap())).unwrap().children().unwrap(); + // Two rings under four radial dividers, all in the current fill. + assert_eq!(children.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::>(), [true, true, false, false, false, false]); + assert!(children.iter().all(|n| n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None))); + let widths: Vec = children[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap().width()).collect(); + assert!((widths[0] - 100.0).abs() < 1e-6 && (widths[1] - 93.75).abs() < 1e-6, "outward: {widths:?}"); + let tips: Vec = children[3..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p.x).collect(); + assert!(tips.iter().all(|x| *x > 60.0), "right of the top divider: {tips:?}"); + let saved = json!({"concentric": 3, "radial": 4, "concentricSkew": 100.0, "radialSkew": 500.0, "compound": true, "fill": true}); + assert_eq!(app.session.tool_options_of("polarGrid"), saved); +} + #[test] fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs b/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs index aa68cfa2..8d9f8c56 100644 --- a/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs +++ b/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs @@ -85,11 +85,17 @@ fn assert_path(path: &PathData, expected: &[Point], closed: bool) { } } -/// Independent coordinate oracle: counts mean interior dividers, plus one closed outer frame. +/// Independent coordinate oracle: one closed outer frame at the bottom, then the interior +/// dividers the counts mean. fn assert_grid(app: &VectorcraftApp, id: NodeId, bounds: Rect, rows: u32, columns: u32) { let paths = paths(app, id); assert_eq!(paths.len(), (rows + columns + 1) as usize); let mut remaining = paths.iter(); + assert_path( + remaining.next().unwrap(), + &[Point::new(bounds.x0, bounds.y0), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)], + true, + ); for i in 1..=rows { let y = bounds.y0 + bounds.height() * f64::from(i) / f64::from(rows + 1); assert_path(remaining.next().unwrap(), &[Point::new(bounds.x0, y), Point::new(bounds.x1, y)], false); @@ -98,11 +104,6 @@ fn assert_grid(app: &VectorcraftApp, id: NodeId, bounds: Rect, rows: u32, column let x = bounds.x0 + bounds.width() * f64::from(i) / f64::from(columns + 1); assert_path(remaining.next().unwrap(), &[Point::new(x, bounds.y0), Point::new(x, bounds.y1)], false); } - assert_path( - remaining.next().unwrap(), - &[Point::new(bounds.x0, bounds.y0), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)], - true, - ); assert!(remaining.next().is_none()); } @@ -196,7 +197,8 @@ fn rectangular_grid_creates_exact_dividers_as_one_undo_redo_step() { assert_eq!(app.session.doc().unwrap().doc, before.doc); assert_eq!(app.session.doc().unwrap().selection.objects, before.selected); assert_eq!(app.session.doc().unwrap().history.undo.len(), before.undo); - assert_eq!(app.session.tool_options_of(KIND), json!({"rows": 2, "columns": 3}), "undo does not undo the saved tool settings"); + let saved = json!({"rows": 2, "columns": 3, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false}); + assert_eq!(app.session.tool_options_of(KIND), saved, "undo does not undo the saved tool settings"); app.run("edit.redo", json!({})).unwrap(); assert_eq!(app.session.doc().unwrap().doc, created); assert_grid(&app, id, Rect::new(125.0, 75.0, 245.0, 135.0), 2, 3); @@ -273,16 +275,16 @@ fn rectangular_grid_perspective_click_projects_each_anchor_and_is_one_undo_step( values(&mut app, json!(96), json!(54), json!(1), json!(2)); let id = confirm(&mut app); let expected = [ + vec![at, Point::new(at.x + 96.0, at.y), Point::new(at.x + 96.0, at.y + 54.0), Point::new(at.x, at.y + 54.0)], vec![Point::new(at.x, at.y + 27.0), Point::new(at.x + 96.0, at.y + 27.0)], vec![Point::new(at.x + 32.0, at.y), Point::new(at.x + 32.0, at.y + 54.0)], vec![Point::new(at.x + 64.0, at.y), Point::new(at.x + 64.0, at.y + 54.0)], - vec![at, Point::new(at.x + 96.0, at.y), Point::new(at.x + 96.0, at.y + 54.0), Point::new(at.x, at.y + 54.0)], ]; let actual = paths(&app, id); assert_eq!(actual.len(), expected.len()); for (index, (path, points)) in actual.iter().zip(expected).enumerate() { let projected: Vec = points.into_iter().map(|p| map.apply(p).unwrap()).collect(); - assert_path(path, &projected, index == 3); + assert_path(path, &projected, index == 0); } let state = app.session.doc().unwrap(); assert_eq!(PerspectiveGrid::effective(&state.doc).attachment_of(&state.doc, id), Some((Plane::Right, 0.0))); @@ -311,3 +313,63 @@ fn rectangular_grid_failed_creation_does_not_save_counts() { assert_eq!(app.session.tool_options_of(KIND), before.options); assert_eq!(app.session.doc().unwrap().history.undo.len(), before.undo); } + +/// #1377: the dialog shows the tool's stored skews (sliders under their divider counts) and +/// switches; OK draws with them and stores them, and the next drag uses them too. +#[test] +fn rectangular_grid_dialog_edits_skews_frame_and_fill() { + let mut app = app(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + app.run("tool.setOption", json!({"tool": KIND, "values": {"rowSkew": 100, "frame": false}})).unwrap(); + click(&mut app, Point::new(10.0, 20.0)); + assert_eq!((field(&app, "rowSkew"), field(&app, "columnSkew")), (json!(100.0), json!(0.0))); + assert_eq!((field(&app, "frame"), field(&app, "fill")), (json!(false), json!(false))); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + let labels = [ + "Horizontal Dividers:", + "Skew:", + "Bottom", + "Top", + "Vertical Dividers:", + "Skew:", + "Left", + "Right", + "Use Outside Rectangle As Frame", + "Fill Grid", + ]; + let mut from = 0; + for label in labels { + from += text[from..].find(&format!("{label}\n")).unwrap_or_else(|| panic!("no {label:?} in order in {text}")) + label.len(); + } + assert!(!text.contains("Row Skew") && !text.contains("false"), "the skews and switches aren't plain fields: {text}"); + values(&mut app, json!(100), json!(100), json!(3), json!(1)); + set(&mut app, "columnSkew", json!("-250")); + set(&mut app, "fill", json!(true)); + let id = confirm(&mut app); + let paths = paths(&app, id); + // No frame: four sides, then the dividers, crowded toward the top (100 %: 1/16, 1/4 and 9/16 down). + assert_eq!(paths.len(), 8); + assert!(paths.iter().all(|p| !p.is_closed())); + for (path, y) in paths[4..7].iter().zip([6.25, 25.0, 56.25]) { + assert_path(path, &[Point::new(10.0, 20.0 + y), Point::new(110.0, 20.0 + y)], false); + } + let x = paths[7].subpaths[0].anchors[0].p.x; + assert!(x > 10.0 && x < 30.0, "toward the left: {x}"); + let saved = json!({"rows": 3, "columns": 1, "rowSkew": 100.0, "columnSkew": -250.0, "frame": false, "fill": true}); + assert_eq!(app.session.tool_options_of(KIND), saved); + // A drag draws with the same options. + pointer(&mut app, PointerKind::Down, Point::new(300.0, 300.0)); + pointer(&mut app, PointerKind::Drag, Point::new(400.0, 400.0)); + pointer(&mut app, PointerKind::Up, Point::new(400.0, 400.0)); + let dragged = app.session.doc().unwrap().selection.objects[0]; + assert_ne!(dragged, id); + let doc = &app.session.doc().unwrap().doc; + let offset = + |id: NodeId| doc.node(id).unwrap().children().unwrap().iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect::>(); + for (a, b) in offset(id).iter().zip(offset(dragged)) { + assert!((b.x - a.x - 290.0).abs() < 1e-9 && (b.y - a.y - 280.0).abs() < 1e-9, "{a:?} {b:?}"); + } + assert!( + doc.node(dragged).unwrap().children().unwrap().iter().all(|n| n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)) + ); +} diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index 2697a48d..11711aef 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -16,16 +16,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { "polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})), "star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})), "lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})), - "rectangularGrid" => { - let options = app.session.tool_options_of(kind); - Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "rows": options["rows"], "columns": options["columns"]})) - } - "polarGrid" => { - let options = app.session.tool_options_of(kind); - Dialog::new( - kind, - json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "concentric": options["concentric"], "radial": options["radial"]}), - ) + // The grids: the tool's stored options (divider counts and skews, switches) at the default size. + "rectangularGrid" | "polarGrid" => { + let mut fields = app.session.tool_options_of(kind); + for (key, value) in [("x", json!(x)), ("y", json!(y)), ("width", json!("100 pt")), ("height", json!("100 pt"))] { + fields[key] = value; + } + Dialog::new(kind, fields) } // Graph tool click: the graph's size. "graph" => Dialog::new( diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c25d957a..07fa5c13 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4502,3 +4502,10 @@ mark-style Japanese Japonské @msg the file stopped being read (please report this bug) čtení souboru se přerušilo (nahlaste prosím tuto chybu) Concentric Dividers: Soustředné dělicí čáry: Radial Dividers: Radiální dělicí čáry: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Zkosení: + Inside Uvnitř + Outside Vně + Use Outside Rectangle As Frame Použít vnější obdélník jako rámeček + Create Compound Path From Ellipses Vytvořit složenou cestu z elips + Fill Grid Vyplnit mřížku diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e889bcee..27b396b3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4153,3 +4153,10 @@ theme Appearance Erscheinungsbild @msg the file stopped being read (please report this bug) das Lesen der Datei wurde abgebrochen (bitte melden Sie diesen Fehler) Concentric Dividers: Konzentrische Trennlinien: Radial Dividers: Radiale Trennlinien: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Neigung: + Inside Innen + Outside Außen + Use Outside Rectangle As Frame Äußeres Rechteck als Rahmen verwenden + Create Compound Path From Ellipses Zusammengesetzten Pfad aus Ellipsen erstellen + Fill Grid Raster füllen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1f7716f2..a7a21fa6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4196,6 +4196,13 @@ lighting Light Luz @msg the file stopped being read (please report this bug) se dejó de leer el archivo (informe de este error) Concentric Dividers: Divisiones concéntricas: Radial Dividers: Divisiones radiales: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Sesgo: + Inside Dentro + Outside Fuera + Use Outside Rectangle As Frame Usar rectángulo exterior como marco + Create Compound Path From Ellipses Crear trazado compuesto a partir de elipses + Fill Grid Rellenar cuadrícula # Rows other catalogs already had: document and print presets, keyboard-shortcut editor, print and # PDF options, arrowheads, harmony rules, Save for Web, graphic styles and file filters. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 22ac83f9..8afa2f02 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4206,3 +4206,10 @@ lighting Light Lumière @msg the file stopped being read (please report this bug) la lecture du fichier s’est interrompue (veuillez signaler ce bogue) Concentric Dividers: Divisions concentriques : Radial Dividers: Divisions radiales : +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Inclinaison : + Inside Intérieur + Outside Extérieur + Use Outside Rectangle As Frame Utiliser le rectangle extérieur comme cadre + Create Compound Path From Ellipses Créer un tracé transparent à partir des ellipses + Fill Grid Remplir la grille diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 22354688..d235c823 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4596,3 +4596,10 @@ theme Appearance Kinézet @msg the file stopped being read (please report this bug) a fájl beolvasása megszakadt (kérjük, jelentsd ezt a hibát) Concentric Dividers: Koncentrikus osztók: Radial Dividers: Sugárirányú osztók: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Ferdítés: + Inside Belül + Outside Kívül + Use Outside Rectangle As Frame Külső téglalap használata keretként + Create Compound Path From Ellipses Összetett útvonal létrehozása az ellipszisekből + Fill Grid Rács kitöltése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 23ccf705..b44fe72b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4198,3 +4198,10 @@ lighting Light Luce @msg the file stopped being read (please report this bug) la lettura del file si è interrotta (segnala questo errore) Concentric Dividers: Divisori concentrici: Radial Dividers: Divisori radiali: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Inclinazione: + Inside Interno + Outside Esterno + Use Outside Rectangle As Frame Usa rettangolo esterno come cornice + Create Compound Path From Ellipses Crea tracciato composto dalle ellissi + Fill Grid Riempi griglia diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0e72f49c..58f0d165 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4192,3 +4192,10 @@ lighting Light ライト @msg the file stopped being read (please report this bug) ファイルの読み込みが中断されました (このバグを報告してください) Concentric Dividers: 同心円の分割線: Radial Dividers: 放射状の分割線: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: 歪み: + Inside 内側 + Outside 外側 + Use Outside Rectangle As Frame 外側の長方形をフレームとして使用 + Create Compound Path From Ellipses 楕円形から複合パスを作成 + Fill Grid グリッドを塗りつぶす diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b9754ed9..e0e8d71c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4162,3 +4162,10 @@ lighting Light 광원 @msg the file stopped being read (please report this bug) 파일 읽기가 중단되었습니다(이 버그를 신고해 주세요) Concentric Dividers: 동심원 구분선: Radial Dividers: 방사형 구분선: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: 기울임: + Inside 안쪽 + Outside 바깥쪽 + Use Outside Rectangle As Frame 바깥쪽 사각형을 프레임으로 사용 + Create Compound Path From Ellipses 원에서 컴파운드 패스 만들기 + Fill Grid 격자 칠하기 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 04e66fd0..bc0f1464 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3679,3 +3679,10 @@ axis Both Ambos @msg the file stopped being read (please report this bug) a leitura do arquivo foi interrompida (informe este erro) Concentric Dividers: Divisórias concêntricas: Radial Dividers: Divisórias radiais: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Inclinação: + Inside Dentro + Outside Fora + Use Outside Rectangle As Frame Usar Retângulo Externo como Moldura + Create Compound Path From Ellipses Criar Caminho Composto a partir das Elipses + Fill Grid Preencher Grade diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4f8f17fa..2ee2eb1e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4194,3 +4194,10 @@ lighting Light Свет @msg the file stopped being read (please report this bug) чтение файла прервалось (пожалуйста, сообщите об этой ошибке) Concentric Dividers: Концентрические разделители: Radial Dividers: Радиальные разделители: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Скос: + Inside Внутри + Outside Снаружи + Use Outside Rectangle As Frame Использовать внешний прямоугольник как рамку + Create Compound Path From Ellipses Создать составной контур из эллипсов + Fill Grid Залить сетку diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b634b79f..563ec3b6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4178,3 +4178,10 @@ lighting Light Світло @msg the file stopped being read (please report this bug) читання файлу перервалося (будь ласка, повідомте про цю помилку) Concentric Dividers: Концентричні розділювачі: Radial Dividers: Радіальні розділювачі: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: Скіс: + Inside Усередині + Outside Зовні + Use Outside Rectangle As Frame Використовувати зовнішній прямокутник як рамку + Create Compound Path From Ellipses Створити складений контур з еліпсів + Fill Grid Залити сітку diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1cfbfb57..b3a81524 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3790,3 +3790,10 @@ lighting Light 光源 Highlight Area 高光区域 Concentric Dividers: 同心分隔线: Radial Dividers: 径向分隔线: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: 倾斜: + Inside 内侧 + Outside 外侧 + Use Outside Rectangle As Frame 使用外部矩形作为框架 + Create Compound Path From Ellipses 从椭圆形创建复合路径 + Fill Grid 网格填色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 20156cee..b00bc7d8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3571,3 +3571,10 @@ lighting Light 光源 Highlight Area 亮部範圍 Concentric Dividers: 同心分隔線: Radial Dividers: 徑向分隔線: +# Grid tool options (the Rectangular Grid and Polar Grid click dialogs). + Skew: 傾斜: + Inside 內側 + Outside 外側 + Use Outside Rectangle As Frame 使用外部矩形作為框架 + Create Compound Path From Ellipses 從橢圓形建立複合路徑 + Fill Grid 格線填色 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ef59e9f..5b5fbda8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -429,7 +429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. - - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). + - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. @@ -519,6 +519,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Rectangular Grid and Polar Grid tool options (skew, frame, compound path, Fill Grid; #1377, in part); skew curve our own, no reference point locator; scores and hours unchanged (estimated) | | 2026-10-12 | minor | File formats: placed (linked) files in a `.ai`'s editing data come in as linked images showing their saved preview, so such a file opens through its layers with its guides (#1264); own-fixture reader, lexer and link tests; score and hours unchanged (estimated) | | 2026-10-12 | minor | File formats: gradient-filled shapes import as paths (#1304); constant alpha soft masks export white for luminosity readers (#1286, in part); scores and hours unchanged (estimated) | | 2026-10-12 | minor | Polar Grid click dialog (#1192); `.ai` images on hidden lines read (#1264, in part); Affinity rectangles keep per-corner radii and cut corners (#666, in part); Tools panel buttons centred (#1363); Properties panel width with Image Trace (#1358, in part); scores and hours unchanged (estimated) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1f3f3faa..86fa542f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -124,6 +124,7 @@ tools, never checked side by side) isn't captured row by row. | Field precision: 3 decimals for steps; values kept as f64 | done | | | Scrubby labels (beyond Illustrator) | done | #400 | | Dialogs open with the first field focused and selected; Enter confirms | done | | +| Rectangular Grid and Polar Grid click dialogs: size, divider counts with a skew slider each, Use Outside Rectangle As Frame / Create Compound Path From Ellipses, Fill Grid; kept as the tool's options, used by drags too | partial | #1377. The options are the documented ones; how far a skew percentage moves the dividers and which way the radial skew turns are our own choice (the public documentation gives neither), and the reference point locator and a double-click on the tool are missing | ## Modifier keys and temporary tools @@ -186,6 +187,7 @@ re-measure of the panels added since. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Rectangular Grid and Polar Grid tool options (skew, frame, compound path, Fill Grid; #1377, in part): a skew slider under each divider count, kept as tool options and used by drags; the skew curve is our own; score unchanged (estimated) | | 2026-10-12 | minor | Live Corners: a Direct Selection click on a widget selects its corner, Shift-click toggles it (#1239); #908 and #1232 stay open; score unchanged (estimated) | | 2026-10-12 | minor | Custom ruler origin (#1212): corner drag previews and sets it; double-click resets to the active artboard; X/Y fields use it and native files keep it; scores unchanged (estimated) | | 2026-10-12 | minor | Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged | From 689ad469991df361713e50601cba67de3c646ef3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 18:44:45 +0700 Subject: [PATCH 10/13] A just-opened dialog gives its focus to the first enabled field, not a greyed-out one --- crates/ui-egui/src/dialogs/mod.rs | 4 ++-- crates/ui-egui/src/dialogs/tests.rs | 31 +++++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 11 +++++----- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 21325b77..cd8b38e8 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -412,8 +412,8 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room))); modal::heading(ui, &heading); ui.add_space(12.0); - // Just opened: its first field takes the keyboard focus, as in the reference app (type a - // value, press Enter). Only the body's fields can take it. + // Just opened: its first enabled field takes the keyboard focus, as in the reference app + // (type a value, press Enter). Only the body's fields can take it. if focus_first { ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true)); } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 36bc7ae4..7a9068aa 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -566,6 +566,37 @@ fn a_dialog_opened_by_a_click_focuses_its_first_field_each_time_it_opens() { } } +/// A greyed-out field can't hold the keyboard focus, so it doesn't take a just-opened dialog's +/// either: the focus waits for the first enabled field. Blend Options on Smooth Color shows its +/// steps greyed out; choosing Specified Steps enables the field and it takes the focus (type a +/// count, press Enter). +#[test] +fn a_dialogs_focus_skips_a_greyed_out_first_field() { + let mut app = app(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + app.run("ui.blendOptions", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("spacing".into(), json!("smooth")); + for _ in 0..3 { + frame(&mut app, vec![]); + } + assert!(ctx.memory(|m| m.focused()).is_none(), "nothing to type in yet"); + app.ui.dialog.as_mut().unwrap().fields.insert("spacing".into(), json!("steps")); + frame(&mut app, vec![]); + assert!(ctx.memory(|m| m.focused()).is_some(), "the steps field takes the focus once it is enabled"); + frame(&mut app, vec![egui::Event::Text("7".into())]); + frame(&mut app, vec![enter]); + assert!(app.ui.dialog.is_none()); + let info = app.run("object.blend.info", json!({})).unwrap(); + assert_eq!((info["spacing"].clone(), info["steps"].clone()), (json!("steps"), json!(7)), "{info}"); +} + /// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed /// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid. #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 723d4388..093852b7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -280,16 +280,17 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> } /// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the -/// first field drawn takes it ([`take_dialog_focus`]). +/// first enabled field drawn takes it ([`take_dialog_focus`]). pub(crate) fn dialog_focus_flag() -> egui::Id { egui::Id::new("dialog-focus-first-field") } -/// If field `id` (showing `text`) is the first field of a dialog that has just opened, give it the -/// keyboard focus with all of `text` selected: typing replaces the value and Enter applies it. +/// If field `id` (showing `text`) is the first enabled field of a dialog that has just opened, give +/// it the keyboard focus with all of `text` selected: typing replaces the value and Enter applies it. pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) { - // A new window's first frame only measures its contents: nothing can take focus yet. - if ui.is_sizing_pass() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { + // A new window's first frame only measures its contents: nothing can take focus yet. Nor can a + // greyed-out field (egui drops its focus): the flag stays for the first field that is enabled. + if ui.is_sizing_pass() || !ui.is_enabled() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { return; } focus_field(ui, id, text); From 106925c74bfa1c1917fb009e15634cbb54dccf67 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 19:36:41 +0700 Subject: [PATCH 11/13] =?UTF-8?q?Docs:=20ROADMAP=202026-10-12=20adds=20#13?= =?UTF-8?q?75,=20#1376,=20#1379,=20#1382,=20#1383,=20#1386,=20#1390?= =?UTF-8?q?=E2=80=93#1392,=20#1395?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 4 ++-- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 3 ++- docs/roadmap.md | 3 ++- docs/target-app-parity.md | 9 ++++++--- docs/type-parity.md | 3 ++- docs/ui-parity.md | 5 +++-- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 43eddc80..58f678cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -130,7 +130,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). | | 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. | | 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. | -| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undo step (#1331). EPS import no longer hangs on shading functions fanned out through shared arrays (#1330), looped huge arcs, fine dashes and rectangle arrays (#1329), or very long dash patterns (#1332). Plug-in whole-number fields keep values past 2^53 exactly as typed (#1327). Large documents use far less memory: decoded images are shared and capped, undo history is bounded by memory, native files open in the background and save as a stream, and Data Recovery writes images once (#1245). Opacity mask commands (#1336), Simplify and Add Anchor Points (#1337) add no undo step when they change nothing. EPS files with thousands of tiling patterns, spot inks or clipped shadings open several times faster with the same result (#1341). A `.ai` or PDF saved from a CMYK document with transparency no longer opens blank in applications that read soft masks by luminosity (#1359, part of #1286). Gradient-filled shapes in a PDF or `.ai` open as one path with the gradient instead of a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on their outlines (#1351). A `.ai` saved with a bleed opens on its artboard with the bleed round it as the document's bleed, from its layers, and Crop To › Trim does the same for a PDF (#1349); a PDF saved with its editing data opens from it, with its layers, groups and art past the page, as a `.ai` does (#1345). Art moved into a hidden or locked layer (row drag, Merge Selected, Flatten Artwork, Collect in New Layer) or hidden by a data set is deselected (#1355), and hiding or locking a layer with a large selection deselects its objects in linear time (200,000 objects: 3.9 s to 24 ms) (#1353). Objects moved whole together with direct-selected anchors follow Preferences › Transform Pattern Tiles (#1354). Preferences › Appearance of Black › On Screen works: Display All Blacks as Rich Black shows 100K black as RGB black on the canvas (#1350). DXF: a closed control-point SPLINE opens as a closed path (#1318). Numeric fields: dragging over part of a value in an unfocused field selects that part instead of the whole value (#1339). Pathfinder Divide: an open line or curve across a filled shape cuts it into separate pieces (#1335); Unite, Minus Front, Minus Back, Intersect and Exclude results keep the source object's name, so exported SVG keeps its id (#1308). Object › Align follows the Align To choice (one object aligns to the artboard), and the choice is kept across restarts (#1347). Artboard tool: a click on an object creates an artboard fitted to it (#1252). Rulers: drag the ruler corner to move the zero point, double-click it to reset; the X/Y fields measure from it (#1250). Live Corners: with Direct Selection, a click on a corner widget selects that corner, so the next drag rounds it alone (#1239). A click with the Polar Grid tool opens a dialog for its size and divider counts, as the Rectangular Grid tool does (#1192). A `.ai` whose editing data holds an image on hidden lines is no longer misread (#1364, part of #1264). PDF export leaves out art placed beyond 1,000,000,000 pt instead of aborting debug and test builds (#1360). Affinity import: rectangles keep per-corner radii and straight (cut) corners (#1369, part of #666). Tools panel: the tool buttons are centred inside the panel border, in one and two columns (#1363). Spanish: 371 more interface strings translated (presets, shortcut editor, print and PDF options, Save for Web and more) (#1371). The Properties panel no longer widens and cuts off its labels when an Image Trace object is selected (#1374, part of #1358). | +| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undo step (#1331). EPS import no longer hangs on shading functions fanned out through shared arrays (#1330), looped huge arcs, fine dashes and rectangle arrays (#1329), or very long dash patterns (#1332). Plug-in whole-number fields keep values past 2^53 exactly as typed (#1327). Large documents use far less memory: decoded images are shared and capped, undo history is bounded by memory, native files open in the background and save as a stream, and Data Recovery writes images once (#1245). Opacity mask commands (#1336), Simplify and Add Anchor Points (#1337) add no undo step when they change nothing. EPS files with thousands of tiling patterns, spot inks or clipped shadings open several times faster with the same result (#1341). A `.ai` or PDF saved from a CMYK document with transparency no longer opens blank in applications that read soft masks by luminosity (#1359, part of #1286). Gradient-filled shapes in a PDF or `.ai` open as one path with the gradient instead of a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on their outlines (#1351). A `.ai` saved with a bleed opens on its artboard with the bleed round it as the document's bleed, from its layers, and Crop To › Trim does the same for a PDF (#1349); a PDF saved with its editing data opens from it, with its layers, groups and art past the page, as a `.ai` does (#1345). Art moved into a hidden or locked layer (row drag, Merge Selected, Flatten Artwork, Collect in New Layer) or hidden by a data set is deselected (#1355), and hiding or locking a layer with a large selection deselects its objects in linear time (200,000 objects: 3.9 s to 24 ms) (#1353). Objects moved whole together with direct-selected anchors follow Preferences › Transform Pattern Tiles (#1354). Preferences › Appearance of Black › On Screen works: Display All Blacks as Rich Black shows 100K black as RGB black on the canvas (#1350). DXF: a closed control-point SPLINE opens as a closed path (#1318). Numeric fields: dragging over part of a value in an unfocused field selects that part instead of the whole value (#1339). Pathfinder Divide: an open line or curve across a filled shape cuts it into separate pieces (#1335); Unite, Minus Front, Minus Back, Intersect and Exclude results keep the source object's name, so exported SVG keeps its id (#1308). Object › Align follows the Align To choice (one object aligns to the artboard), and the choice is kept across restarts (#1347). Artboard tool: a click on an object creates an artboard fitted to it (#1252). Rulers: drag the ruler corner to move the zero point, double-click it to reset; the X/Y fields measure from it (#1250). Live Corners: with Direct Selection, a click on a corner widget selects that corner, so the next drag rounds it alone (#1239). A click with the Polar Grid tool opens a dialog for its size and divider counts, as the Rectangular Grid tool does (#1192). A `.ai` whose editing data holds an image on hidden lines is no longer misread (#1364, part of #1264). PDF export leaves out art placed beyond 1,000,000,000 pt instead of aborting debug and test builds (#1360). Affinity import: rectangles keep per-corner radii and straight (cut) corners (#1369, part of #666). Tools panel: the tool buttons are centred inside the panel border, in one and two columns (#1363). Spanish: 371 more interface strings translated (presets, shortcut editor, print and PDF options, Save for Web and more) (#1371). The Properties panel no longer widens and cuts off its labels when an Image Trace object is selected (#1374, part of #1358). Art drawn flush with an artboard (a background a rounding error outside it) moves, copies and scales with the artboard again (#1375, from #1295). A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992). A PDF saved with its editing data and a bleed opens on its artboard with the bleed round it, as a `.ai` does (#1379, follow-up to #1349 and #1345). Dialogs opened from a menu focus their first field every time they open, not only the first time, so typing, Tab and Enter work (#1382, fixes #1367); a dialog whose first field is greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the keyboard focus to that field once it is enabled (#1395). In a `.ai` opened from its editing data, a gradient with stops in the middle draws across its whole shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392) (follow-ups to #1264). `--control` without a valid port (or a bad `VECTORCRAFT_CONTROL_PORT`) stops the desktop app with an error instead of starting without a control server, and `--control=` works too (#1383). Documents with many symbol instances or inline graphics redraw faster and use less memory: the art placed for each instance is kept between frames (#1386). | | 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). A shape far beyond any page (from a malformed file) is left undrawn instead of making the renderer abort the app. Live Corners round corners where curved segments meet too, trimming each curve to what the cut leaves of it (#1144). Effect › Distort & Transform › Zig Zag's dialog has sliders for Size and Ridges per segment, and Relative / Absolute and Smooth / Corner as radio buttons (#1133). Object › Path › Offset Path opens on the offset, joins and miter limit it was last applied with (#1152). A marquee selects only the objects whose paths it touches: one drawn over a background, in Preview or Outline, no longer selects the background too (#1103). On Linux, Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where files dropped from a file manager arrive (#1151). A selected gradient mesh shows its mesh lines and points with the Selection and Direct Selection tools too, not only with the Mesh tool (#1111). A blend's spine bends: the Anchor Point tool drags any of its segments into a curve, straight ones too, and Direct Selection reshapes its curved segments (`object.blend.spine.reshapeSegment`, #1111). Alt-click on a hidden layer's eye shows that layer as it hides the others, and on a locked layer's lock unlocks it as it locks the others: the layer clicked is the one left in view (#1158). Object › Compound Path › Release splits a path of several subpaths (an SVG `d` with several `M`s, a path drawn in one go over MCP) into one path per subpath, with its paint and stacking order, as it does a compound path (#1163). MCP `render_preview` takes an `artboard` index, so an agent can look at every page of a multi-artboard document (#1163). The MCP drawing tools say that omitted paint follows the new-art appearance (a white fill and 1 pt black stroke at first), and `export` says which formats take `outlineText` (#1148). The desktop app refuses a malformed `--automation-read-root` / `--automation-write-root` (no folder, an empty one, another option or the flag twice) before it starts, instead of dropping the root its environment variable set and leaving the control channel unconfined (#1157). PDF export warns, once per object, when the 64 million pixel cap renders its raster effects below the document's raster effects resolution, with the requested and the achieved ppi (#1147). MCP `command_batch` with `atomic: true` makes the whole batch one undo step, named by `label` (`edit.undoGroup.begin`/`end`, #1163). `file.list {path}` lists a folder's files and folders for agents, held to the automation read root (#1163). Thai typed in a font without Thai glyphs is set in an installed font made for Thai (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai…) instead of whichever face with Thai glyphs loaded first, and Lao, Khmer and Myanmar likewise. With the Type tool, the Left and Right arrows and Delete step over whole clusters (a Thai consonant with its vowel and tone marks, a letter with a combining accent, an emoji sequence), so the caret never lands inside one; Backspace still takes back one character at a time, a mistyped tone mark say. Thai, Lao, Khmer and Myanmar area type breaks its lines between words, which those scripts don't separate with spaces, instead of running on to the next space; the words come from ICU4X's line segmenter (its dictionaries on the desktop, its smaller LSTM models on the web). The font menu's script filter has Thai beside Japanese and Latin: the families whose character map has Thai. With craft-fonts' Noto Sans Thai, Thai is set in it where no Thai font is installed, and the web build fetches it before the app starts, so Thai no longer shows as empty boxes there. With the Type tool, Ctrl/Alt+arrows and double-click take Thai, Lao, Khmer and Myanmar text word by word (ภาษา, ไทย…) instead of a whole run without spaces, and a word's vowel and tone marks (and combining accents) stay part of it. Releases embed the current craft-fonts (Noto Sans Thai, Noto Sans Arabic and Noto Sans CJK SC beside the Japanese fonts), as the other craft apps' releases do. Object › Compound Path › Make cuts holes where the paths in front overlap the backmost one, also when they were drawn running the same way (#1197). On Windows, Direct3D 12 shaders compile with FXC, part of Windows, so a dxcompiler.dll another program left on the search path without its dxil.dll no longer loses the graphics device at start-up and drops the app to OpenGL (#1194). With anchor points selected by Direct Selection, the Align buttons align those points (to their own bounds or the artboard) instead of moving the whole path (#1180). With Smart Guides, an object grabbed by an anchor and dropped near another object's anchor lands exactly on it, instead of its bounds lining up a fraction of a point beside it (#955). .ai import: a copied area type or type-on-a-path object now sits where its own art puts it, not on top of the original (#1208). EPS/.ai import: a font is found by its exact PostScript name before the name is split, so families with hyphens keep their name (#1207). EPS/.ai import: type from the text document keeps its line spacing across paragraphs instead of applying the paragraph spacing twice (#1206). Brazilian Portuguese translates the remaining UI labels and engine messages (#1205). Czech joins the complete languages: panels, dialogs, plurals and status and error messages (#1189). Type: Digits (Character panel menu, with the Middle Eastern options) draws every digit in the Default, Arabic, Hindi or Farsi numeral system (#1201). Linux: under Wayland, Automatic draws on the GPU the compositor says it draws with, so a hybrid laptop whose compositor runs on the discrete GPU no longer opens an empty window (#1200). Large documents: editing a big selection takes time linear in the document (a 25,000-path nudge 3.5 s → 48 ms), live geometry effects are kept across frames, Pathfinder Trim, Merge, Crop and Outline are 9–20× faster with identical output, and undo history holds 41% less (#1173). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6cce866f..3ba7c5db 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public @@ -47,7 +47,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -83,6 +83,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.ai` editing data: a gradient with middle stops runs its whole ramp (#1390); type of objects with an appearance, of hidden layers and on later artboards goes to its own text object (#1391); area type in a frame too short for the fallback font shows its first line (#1392); scores unchanged | | 2026-10-12 | minor | `.ai` editing data: placed (linked) files come in as linked images showing the preview the file keeps, so a file with them opens through its layers, with its guides (#1264); scores unchanged | | 2026-10-12 | minor | PDF and `.ai` import: a shape painted with a gradient opens as one path with the gradient (#1304). PDF and `.ai` export: the constant alpha soft mask of a CMYK page with transparency is white (#1286, in part) | | 2026-10-12 | minor | DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves stay open and curves retained; rational/non-rational import and export versions regression-tested; scores/hours unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 29257a41..7e656c11 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -234,6 +234,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G9: in `.ai` editing data, gradients with middle stops (#1390), type of objects with an appearance, hidden layers and later artboards (#1391) and area type in a frame too short for the fallback font (#1392) open as saved; G15: the art placed for symbol instances and inline graphics is kept between frames (#1386; the budget run on an idle machine remains); G1: dialogs take the keyboard focus on every open (#1367, #1395); estimates unchanged | | 2026-10-12 | minor | G9: placed (linked) files in `.ai` editing data come in as linked images showing their saved preview, so such files open through their layers with their guides (#1264); estimate unchanged | | 2026-10-12 | minor | G9: gradient-filled shapes from a PDF or `.ai` open as paths, not clip groups (#1304); CMYK files with transparency show in applications reading soft masks by luminosity (#1286, in part: the layer groups' bounding boxes remain); G15: hiding or locking a layer deselects in linear time (#1353); estimates unchanged | | 2026-10-12 | minor | G7: custom document ruler origin (#1212) done; global/artboard mode switching and video rulers remain, estimate unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 0471a5fa..2022dabc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -80,6 +80,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M4: `.ai` editing data follow-ups to #1264 (gradient stops #1390, type placement #1391, short area-type frames #1392) and PDF bleed with editing data (#1379); M14: dialogs take the keyboard focus on every open (#1367, #1395); M5: symbol instance art kept between frames (#1386); M7: characters no font had are looked for again after a font rescan (#1376); estimates unchanged | | 2026-10-12 | minor | M4: gradient-filled shapes from a PDF or `.ai` open as paths (#1304); constant alpha soft masks export white (#1286, in part); M2: a Direct Selection click on a Live Corners widget selects its corner (#1239); estimates unchanged | | 2026-10-12 | minor | M2: custom ruler origin (#1212), with drag preview, active-artboard reset, relative X/Y and native round-trip; remaining ruler modes and estimates unchanged | | 2026-10-12 | minor | M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5b5fbda8..78f5bd30 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -406,7 +406,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Four brightness themes, persistent preferences. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). About 16 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black › Printing / Exporting, Touch Workspace). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. @@ -492,7 +492,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). - Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). + Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked; CIRCLE/ARC fitting, CNC preset and VCarve QA remain pending. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). @@ -519,6 +519,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | File formats: `.ai` editing data follow-ups to #1264: gradients with middle stops (#1390), type of objects with an appearance, hidden layers and later artboards (#1391), area type in a frame too short for the fallback font (#1392); a PDF saved with its editing data opens with its bleed (#1379); score and hours unchanged (estimated) | +| 2026-10-12 | minor | UI chrome: dialogs take the keyboard focus on every open (#1367) and give it to a first field that starts greyed out once it is enabled (#1395); Artboards: art flush with an artboard within a rounding error moves, copies and scales with it (#1375, from #1295); Type core: a character no font had is looked for again after a font rescan (#1376, from #992); scores and hours unchanged (estimated) | +| 2026-10-12 | minor | Performance: the art placed for symbol instances and inline graphics is kept between frames (#1386; the contributor measured 51.6 → 32.3 ms a frame on 2,000 instances at 2880 × 1800, not re-measured here); budgets still unverified, score unchanged. Automation: `--control` without a valid port stops the desktop app with an error (#1383) | | 2026-10-12 | minor | Rectangular Grid and Polar Grid tool options (skew, frame, compound path, Fill Grid; #1377, in part); skew curve our own, no reference point locator; scores and hours unchanged (estimated) | | 2026-10-12 | minor | File formats: placed (linked) files in a `.ai`'s editing data come in as linked images showing their saved preview, so such a file opens through its layers with its guides (#1264); own-fixture reader, lexer and link tests; score and hours unchanged (estimated) | | 2026-10-12 | minor | File formats: gradient-filled shapes import as paths (#1304); constant alpha soft masks export white for luminosity readers (#1286, in part); scores and hours unchanged (estimated) | diff --git a/docs/type-parity.md b/docs/type-parity.md index f4698d24..427b21aa 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -56,6 +56,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992); scores and hours unchanged | | 2026-10-12 | minor | Full-name font matching and Missing Fonts searches (#1258): synthetic static/variable font regressions; Windows font-service QA pending; scores and hours unchanged | | 2026-10-11 | minor | Middle Eastern: digit types done (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; `text.setFormat` / `text.setRangeStyle {digits}`); Character Direction, kashidas, composers and the split caret still missing; scores unchanged at this rounding | | 2026-10-11 | minor | Thai, Lao, Khmer and Myanmar words for the caret (Ctrl/Alt+arrows) and double-click; combining marks count as part of a word; score unchanged (estimated) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 86fa542f..ee1d26ce 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -123,7 +123,7 @@ tools, never checked side by side) isn't captured row by row. | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | | Field precision: 3 decimals for steps; values kept as f64 | done | | | Scrubby labels (beyond Illustrator) | done | #400 | -| Dialogs open with the first field focused and selected; Enter confirms | done | | +| Dialogs open with the first field focused and selected; Enter confirms | done | on every open, not only the first (#1367); a first field that starts greyed out takes the focus once enabled (#1395) | | Rectangular Grid and Polar Grid click dialogs: size, divider counts with a skew slider each, Use Outside Rectangle As Frame / Create Compound Path From Ellipses, Fill Grid; kept as the tool's options, used by drags too | partial | #1377. The options are the documented ones; how far a skew percentage moves the dividers and which way the radial skew turns are our own choice (the public documentation gives neither), and the reference point locator and a double-click on the tool are missing | ## Modifier keys and temporary tools @@ -187,6 +187,7 @@ re-measure of the panels added since. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Dialogs opened from a menu take the keyboard focus every time they open (#1367); a first field that starts greyed out takes it once enabled (#1395); art flush with an artboard within a rounding error moves, copies and scales with it (#1375, from #1295); scores and hours unchanged | | 2026-10-12 | minor | Rectangular Grid and Polar Grid tool options (skew, frame, compound path, Fill Grid; #1377, in part): a skew slider under each divider count, kept as tool options and used by drags; the skew curve is our own; score unchanged (estimated) | | 2026-10-12 | minor | Live Corners: a Direct Selection click on a widget selects its corner, Shift-click toggles it (#1239); #908 and #1232 stay open; score unchanged (estimated) | | 2026-10-12 | minor | Custom ruler origin (#1212): corner drag previews and sets it; double-click resets to the active artboard; X/Y fields use it and native files keep it; scores unchanged (estimated) | From bfb173e967ff3e3cf650c09cb29ddb22312b2eb0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 19:29:51 +0700 Subject: [PATCH 12/13] Fix #1388: PDF import keeps overprint --- crates/engine/tests/import_fuzz.rs | 5 +- crates/pdf/src/import.rs | 77 ++++++++++-- crates/pdf/src/import_scan.rs | 147 ++++++++++++++++++++++- crates/pdf/src/import_text.rs | 15 ++- crates/pdf/src/lib.rs | 2 + crates/pdf/src/tests_import_overprint.rs | 136 +++++++++++++++++++++ crates/pdf/src/tests_live_text.rs | 4 + docs/file-format-parity.md | 5 +- docs/target-app-parity.md | 5 +- 9 files changed, 374 insertions(+), 22 deletions(-) create mode 100644 crates/pdf/src/tests_import_overprint.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 6b886e26..430ea6c6 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -713,7 +713,7 @@ fn rich_resources_pdf(content: &str, config: &str) -> Vec { ]; let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5); let resources = format!( - "/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>" + "/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /OP true /OPM 1 /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /OP true /op false /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> /GS2 << /OP 7 /op (x) /OPM -3 >> /GS3 << /op true >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>" ); let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R 99 0 R] /D << {} >> >> ", config.replace("{B}", &format!("{b} 0 R"))); let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); @@ -734,6 +734,9 @@ fn arb_rich_op() -> impl Strategy { "/X1 Do", "/GS0 gs", "/GS1 gs", + // Overprint (#1388): set, malformed, and for fills alone. + "/GS2 gs", + "/GS3 gs", "/CS0 cs 1 0 0 /P0 scn", "/Pattern cs /P0 scn", "/Sh0 sh", diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 743506f2..998b45ca 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -21,13 +21,18 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover}; -use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key}; +use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, all_on, hides_forms, scan_page, tag_key}; use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_shape, mesh_shading, shading_gradient}; use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; /// The name of the paths text imports as. const TEXT_OUTLINES: &str = ""; +/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects. +const OVERPRINT_UNREAD: &str = "the overprint of a page's objects couldn't be read: they knock out"; +/// The document overprints as overprint mode 1 does. +const OVERPRINT_ZERO_MODE: &str = + "overprint in the mode where a process colour's zero tints knock out (/OPM 0) is imported as overprint, where they don't"; /// The deepest soft masks, patterns and type 3 glyphs are read within each other. const MAX_NESTED: u32 = 8; /// A tiling pattern read this many times with the same art is taken to always draw it. @@ -666,6 +671,7 @@ struct GlyphRun { opacity: f32, stroke: Option, scale: f64, + overprint: bool, } /// How often a tiling pattern was read, and the art (as its paints) each reading drew with the @@ -734,6 +740,8 @@ struct Builder<'p> { scan: Scan, tag_at: usize, group_at: usize, + /// The fills and strokes of paths drawn so far ([`Scan::paints`]). + paint_at: usize, aligned: bool, /// Whether optional content groups become layers. route: bool, @@ -880,6 +888,7 @@ impl<'p> Builder<'p> { scan: Scan::default(), tag_at: 0, group_at: 0, + paint_at: 0, aligned: true, route, unsure: false, @@ -930,12 +939,49 @@ impl<'p> Builder<'p> { self.masked.clear(); self.marked.clear(); self.pending = false; - (self.scan, self.tag_at, self.group_at, self.aligned) = (scan, 0, 0, true); + (self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0); + if self.scan.overprints && self.scan.cut { + self.scan.overprints = false; + self.warn(OVERPRINT_UNREAD); + } + if self.scan.overprints && self.scan.zero_mode { + self.warn(OVERPRINT_ZERO_MODE); + } + } + + /// Whether the path the interpreter draws now (a fill or a stroke, each counted) overprints. + fn path_overprint(&mut self) -> bool { + if self.nested > 0 || !self.scan.overprints { + return false; + } + self.paint_at += 1; + self.scan.paints.get(self.paint_at - 1).copied().unwrap_or(false) + } + + /// Whether the glyph the interpreter draws now (its fill, or its `stroke`) overprints. + fn glyph_overprint(&mut self, stroke: bool) -> bool { + if self.nested > 0 || !self.scan.overprints { + return false; + } + match self.scan.texts.get(self.paint_at) { + Some(TextOverprint::All { fill, stroke: s }) => *if stroke { s } else { fill }, + Some(TextOverprint::Mixed) => { + self.warn("some text is set to overprint in a way that can't be read: it knocks out"); + false + } + _ => false, + } } /// The page's art, split by optional content group (in the order they first paint). fn end_page(&mut self) -> Vec<(Option, Vec>)> { - let children = self.close_frames(); + let mut children = self.close_frames(); + // The interpreter drew another number of paths than the file was read to paint: which + // of them overprint can't be trusted. + if self.scan.overprints && self.paint_at != self.scan.paints.len() { + children.iter_mut().for_each(vectorcraft_doc::overprint::clear_overprints); + self.warn(OVERPRINT_UNREAD); + } let groups = std::mem::take(&mut self.root_groups); let mut parts: Vec<(Option, Vec>)> = vec![]; // Thousands of groups: find each one's part by index. @@ -1137,10 +1183,14 @@ impl<'p> Builder<'p> { match &run.stroke { None => { let mut f = FillLayer::new(run.paint); - f.opacity = run.opacity; + (f.opacity, f.overprint) = (run.opacity, run.overprint); ap.items.push(AppearanceItem::Fill(f)); } - Some(p) => ap.items.push(AppearanceItem::Stroke(stroke_layer(run.paint, run.opacity, p, run.scale))), + Some(p) => { + let mut st = stroke_layer(run.paint, run.opacity, p, run.scale); + st.overprint = run.overprint; + ap.items.push(AppearanceItem::Stroke(st)); + } } let id = self.id(); let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap); @@ -1398,6 +1448,7 @@ impl<'p> Builder<'p> { scale: f64, paint: &hayro_interpret::Paint<'_>, stroke: Option<&StrokeProps>, + overprint: bool, ) -> bool { if self.text_as != TextAs::Text || self.nested > 0 { return false; @@ -1489,6 +1540,8 @@ impl<'p> Builder<'p> { version, size: at.size, h_scale: at.h_scale, + overprint_fill: stroke.is_none() && overprint, + overprint_stroke: stroke.is_some() && overprint, fill: stroke.is_none().then_some(paint), stroke, }; @@ -1500,7 +1553,7 @@ impl<'p> Builder<'p> { // A stroke over the glyph just filled (fill and stroke rendering). if let (Some(s), Some(line)) = (&look.stroke, &mut self.text) && look.fill.is_none() - && line.stroke_last(key, at, s.clone()) + && line.stroke_last(key, at, s.clone(), overprint) { return true; } @@ -1566,6 +1619,7 @@ impl<'a> Device<'a> for Builder<'_> { self.flush_glyphs(); self.flush_text(); self.pending = false; + let overprint = self.path_overprint(); let mut bp = path.clone(); bp.apply_affine(transform); if bp.elements().is_empty() { @@ -1605,7 +1659,7 @@ impl<'a> Device<'a> for Builder<'_> { match draw_mode { PathDrawMode::Fill(rule) => { let mut f = FillLayer::new(paint); - f.opacity = opacity; + (f.opacity, f.overprint) = (opacity, overprint); let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() }); if let NodeKind::Path { rule: r, .. } = &mut n.kind { *r = fill_rule(*rule); @@ -1615,7 +1669,8 @@ impl<'a> Device<'a> for Builder<'_> { self.push_node(n); } PathDrawMode::Stroke(props) => { - let st = stroke_layer(paint, opacity, props, mean_scale(transform)); + let mut st = stroke_layer(paint, opacity, props, mean_scale(transform)); + st.overprint = overprint; // Fill-then-stroke of the same path (the `B` operator, or a fill and a stroke of // the outline as Illustrator writes an object) becomes one object: a path with a // fill and a live stroke. @@ -1699,7 +1754,8 @@ impl<'a> Device<'a> for Builder<'_> { match glyph { Glyph::Outline(o) => { let scale = mean_scale(transform); - if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref()) { + let overprint = self.glyph_overprint(stroke.is_some()); + if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) { return; } self.flush_text(); @@ -1709,12 +1765,13 @@ impl<'a> Device<'a> for Builder<'_> { let same = self.glyphs.as_ref().is_some_and(|r| { r.paint == paint && r.opacity == opacity + && r.overprint == overprint && r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width) && (r.scale - scale).abs() < 1e-9 }); if !same { self.flush_glyphs(); - self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale }); + self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint }); } if let Some(r) = &mut self.glyphs { r.path.extend(bp.iter()); diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 667274e8..2d09842e 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -1,12 +1,14 @@ //! What the interpreter doesn't tell the import, read from the file itself: the optional content //! groups (layers) with their states and nesting, which group each marked-content sequence belongs -//! to, the isolate and knockout flags of transparency groups, and the names of the fonts. +//! to, the isolate and knockout flags of transparency groups, the overprint parameters each path +//! and each stretch of text is painted with, and the names of the fonts. //! //! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group //! without its flags, and skips the content of groups that are off. So each page's content is //! walked here in the interpreter's order (page content, then form XObjects as they are drawn, //! skipping what it skips): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag -//! checks that), and the k-th form transparency group is the k-th group found here. To import +//! checks that), the k-th form transparency group is the k-th group found here, and the k-th +//! path it fills or strokes is the k-th one painted here (the page's count checks that). To import //! content that is off as hidden layers, [`all_on`] gives the file an update that turns every //! group on. @@ -219,6 +221,39 @@ pub(crate) struct Scan { pub groups: Vec<(bool, bool)>, /// The walk stopped at [`MAX_OPS`] or [`MAX_TAGS`]: the rest of the page isn't told apart. pub cut: bool, + /// Each fill and each stroke of a path the interpreter draws (a fill-and-stroke operator is + /// a fill, then a stroke; `sh` is a fill): whether it overprints. + pub paints: Vec, + /// How the text shown before each of [`Self::paints`] (since the one before it) overprints, + /// and last the text after them all: one more entry than `paints`. + pub texts: Vec, + /// Something on the page overprints (when nothing does, `paints` and `texts` aren't used). + pub overprints: bool, + /// Something overprints with overprint mode 0, where a `DeviceCMYK` colour's zero components + /// knock out; the document's overprint is the nonzero one (mode 1). + pub zero_mode: bool, +} + +/// How the text shown between two painted paths overprints. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) enum TextOverprint { + /// No text is shown there. + #[default] + None, + /// All of it is shown with these parameters. + All { fill: bool, stroke: bool }, + /// Text is shown with different parameters, which can't be told apart by glyph. + Mixed, +} + +/// The overprint parameters of the graphics state (ISO 32000-1 §8.6.7; `OP`, `op` and `OPM` in +/// Table 58). +#[derive(Clone, Copy, Default)] +struct Overprint { + stroke: bool, + fill: bool, + /// Overprint mode 1 (nonzero overprint). + nonzero: bool, } /// A marked-content tag as [`Scan::tags`] keeps it: its FNV-1a hash (the tags are only compared, @@ -255,6 +290,11 @@ struct Walker<'w, 'p> { out: Scan, /// A form XObject hidden by its own optional content was met. hidden_form: bool, + /// The saved graphics states' overprint parameters, the current one last, pushed and popped + /// as the interpreter does its states. + states: Vec, + /// The text shown since the last painted path. + text: TextOverprint, } impl Walker<'_, '_> { @@ -291,6 +331,49 @@ impl Walker<'_, '_> { self.begin(on); } + fn state(&self) -> Overprint { + self.states.last().copied().unwrap_or_default() + } + + fn save(&mut self) { + self.states.push(self.state()); + } + + /// The interpreter never pops its first state. + fn restore(&mut self) { + if self.states.len() > 1 { + self.states.pop(); + } + } + + fn overprinting(&mut self, on: bool) { + self.out.overprints |= on; + self.out.zero_mode |= on && !self.state().nonzero; + } + + /// The interpreter draws a path: a fill (or a `stroke`) of the current one. + fn painted(&mut self, stroke: bool) { + if !self.is_visible() { + return; + } + let s = self.state(); + let on = if stroke { s.stroke } else { s.fill }; + self.overprinting(on); + self.out.paints.push(on); + self.out.texts.push(std::mem::take(&mut self.text)); + } + + /// The interpreter shows text. + fn shown(&mut self) { + if !self.is_visible() { + return; + } + let s = self.state(); + self.overprinting(s.fill || s.stroke); + let now = TextOverprint::All { fill: s.fill, stroke: s.stroke }; + self.text = if self.text == TextOverprint::None || self.text == now { now } else { TextOverprint::Mixed }; + } + fn note_fonts(&mut self, res: &Resources<'_>) { for (key, _) in res.fonts.entries() { if let Some(f) = res.fonts.get::>(key.as_ref()) @@ -303,6 +386,15 @@ impl Walker<'_, '_> { fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) { self.note_fonts(res); + // The interpreter saves the state for each content stream, and restores what the stream + // left saved when it ends. + let saved = self.states.len(); + self.save(); + self.walk_ops(&mut iter, res, depth); + self.states.truncate(saved); + } + + fn walk_ops(&mut self, iter: &mut TypedIter<'_>, res: &Resources<'_>, depth: u32) { while let Some(op) = iter.next() { self.ops += 1; if self.ops > self.max_ops || self.out.tags.len() >= MAX_TAGS { @@ -338,6 +430,40 @@ impl Walker<'_, '_> { TypedInstruction::EndMarkedContent(_) => { self.visible.pop(); } + TypedInstruction::SaveState(_) => self.save(), + TypedInstruction::RestoreState(_) => self.restore(), + TypedInstruction::SetGraphicsState(gs) => { + if let Some(d) = res.get_ext_g_state(gs.0) + && let Some(s) = self.states.last_mut() + { + let stroke = d.get::(b"OP"); + s.stroke = stroke.unwrap_or(s.stroke); + // Without `/op`, `/OP` sets both. + s.fill = d.get::(b"op").or(stroke).unwrap_or(s.fill); + s.nonzero = d.get::(b"OPM").map_or(s.nonzero, |m| m != 0); + } + } + TypedInstruction::FillPathEvenOdd(_) | TypedInstruction::FillPathNonZero(_) | TypedInstruction::FillPathNonZeroCompatibility(_) => { + self.painted(false); + } + TypedInstruction::StrokePath(_) | TypedInstruction::CloseAndStrokePath(_) => self.painted(true), + TypedInstruction::FillAndStrokeEvenOdd(_) + | TypedInstruction::FillAndStrokeNonZero(_) + | TypedInstruction::CloseFillAndStrokeEvenOdd(_) + | TypedInstruction::CloseFillAndStrokeNonZero(_) => { + self.painted(false); + self.painted(true); + } + // Drawn when the interpreter can read the shading: the page's count tells. + TypedInstruction::Shading(s) => { + if res.get_shading(s.0).is_some() { + self.painted(false); + } + } + TypedInstruction::ShowText(_) + | TypedInstruction::ShowTexts(_) + | TypedInstruction::NextLineAndShowText(_) + | TypedInstruction::ShowTextWithParameters(_) => self.shown(), TypedInstruction::XObject(x) => { if !self.is_visible() || depth >= MAX_DEPTH { continue; @@ -360,7 +486,9 @@ impl Walker<'_, '_> { self.out.groups.push((g.get::(b"I").unwrap_or(false), g.get::(b"K").unwrap_or(false))); } let inner = Resources::from_parent(d.get::>(b"Resources").unwrap_or_default(), res.clone()); + self.save(); self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1); + self.restore(); } if oc.is_some() { self.visible.pop(); @@ -399,8 +527,21 @@ pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: & fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> { let off = if all_on { HashSet::new() } else { ocgs.off.clone() }; - let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false }; + let mut w = Walker { + ocgs, + off, + xref: page.xref(), + fonts, + visible: vec![], + ops: 0, + max_ops: max_ops(), + out: Scan::default(), + hidden_form: false, + states: vec![Overprint::default()], + text: TextOverprint::None, + }; w.walk(page.typed_operations(), page.resources(), 0); + w.out.texts.push(w.text); w } diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index f0f67910..8429aae4 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -81,6 +81,9 @@ pub(crate) struct Look { pub h_scale: f64, pub fill: Option, pub stroke: Option<(Paint, f64)>, + /// The fill, the stroke overprints. + pub overprint_fill: bool, + pub overprint_stroke: bool, } impl Look { @@ -91,7 +94,8 @@ impl Look { && (self.size - other.size).abs() <= self.size * 0.01 && (self.h_scale - other.h_scale).abs() < 0.5 && self.fill == other.fill - && (other.stroke.is_none() || self.stroke == other.stroke) + && self.overprint_fill == other.overprint_fill + && (other.stroke.is_none() || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke)) } fn style(&self) -> CharStyle { @@ -105,6 +109,8 @@ impl Look { fill: self.fill.clone().unwrap_or(Paint::None), stroke, stroke_width, + overprint_fill: self.overprint_fill, + overprint_stroke: self.overprint_stroke, ..CharStyle::default() } } @@ -301,12 +307,13 @@ impl TextLine { } /// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill - /// and stroke rendering): the run is stroked. `false`: it isn't that glyph. - pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool { + /// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't + /// that glyph. + pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool { let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else { return false; }; - look.stroke = Some(stroke); + (look.stroke, look.overprint_stroke) = (Some(stroke), overprint); true } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index fb21896d..b45e084d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -237,6 +237,8 @@ mod tests_import_lines; #[cfg(test)] mod tests_import_options; #[cfg(test)] +mod tests_import_overprint; +#[cfg(test)] mod tests_layers; #[cfg(test)] mod tests_live_text; diff --git a/crates/pdf/src/tests_import_overprint.rs b/crates/pdf/src/tests_import_overprint.rs new file mode 100644 index 00000000..96a7a83e --- /dev/null +++ b/crates/pdf/src/tests_import_overprint.rs @@ -0,0 +1,136 @@ +//! Overprint on import (#1388): the graphics state's `/OP` (strokes), `/op` (fills, `/OP` when +//! absent) and `/OPM` (ISO 32000-1 §8.6.7, Table 58) become the fills' and strokes' overprint. +//! Every file here is written by hand from the specification. + +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; + +use crate::tests_import_fidelity::stream; +use crate::*; + +const ON: &str = "/ExtGState << /Off << /OP false /op false /OPM 0 >> /On << /OP true /op true /OPM 1 >> /Stroke << /OP true /op false /OPM 1 >> /Both << /OP true /OPM 1 >> /Zero << /OP true >> >>"; + +fn open(content: &str, resources: &str, extra: &[&str]) -> ImportReport { + let page = PdfPage { resources: format!("{ON} {resources}"), ..PdfPage::new(400.0, 400.0, content) }; + import_with_report(&pdf_with(&[page], extra, None), &ImportOptions::default()).unwrap() +} + +/// Each painted object in paint order: whether its fill and its stroke overprint (`None`: it has +/// none), type by its first run. +fn overprints(d: &Document) -> Vec<(Option, Option)> { + let mut out = vec![]; + d.walk(|n: &Node| { + if let NodeKind::Text(t) = &n.kind { + out.extend(t.runs.first().map(|r| (Some(r.style.overprint_fill), (!r.style.stroke.is_none()).then_some(r.style.overprint_stroke)))); + } else if !n.appearance.items.is_empty() { + out.push((n.appearance.fill().map(|f| f.overprint), n.appearance.stroke().map(|s| s.overprint))); + } + }); + out +} + +fn about_overprint(r: &ImportReport) -> Vec<&String> { + r.warnings.iter().filter(|w| w.contains("overprint")).collect() +} + +const SPOT: &str = "/ColorSpace << /CS0 [/Separation /Spot1 /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] >>"; + +#[test] +fn a_spot_colour_overprinting_a_process_colour_stays_overprinting_through_export() { + // The file of the report: a cyan square that knocks out, under a spot square that overprints. + let r = open("/Off gs 1 0 0 0 k 50 50 200 200 re f /On gs /CS0 cs 1 scn 150 150 200 200 re f", SPOT, &[]); + assert_eq!(overprints(&r.document), [(Some(false), None), (Some(true), None)]); + assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings); + let spot = r.document.layers[0].children().unwrap().iter().find(|n| n.appearance.fill().is_some_and(|f| f.overprint)).unwrap(); + assert!(format!("{:?}", spot.appearance.fill().unwrap().paint).contains("Spot1"), "the spot square is the one that overprints"); + // Written again it overprints, and comes back the same. + let out = export(&r.document, &PdfOptions::uncompressed()).unwrap(); + assert!(String::from_utf8_lossy(&out).contains("/OP true/op true/OPM 1")); + let again = import_with_report(&out, &ImportOptions::default()).unwrap(); + assert_eq!(overprints(&again.document).iter().filter(|o| o.0 == Some(true)).count(), 1); + assert_eq!(overprints(&again.document).iter().filter(|o| o.0 == Some(false)).count(), 1); +} + +#[test] +fn op_sets_strokes_and_fills_unless_the_state_has_its_own_fill_entry() { + // `/OP` alone is both; with `/op false` only strokes; `B` is one object with both paints. + let r = open("1 0 0 RG 0 0 1 rg /Both gs 10 10 50 50 re B /Stroke gs 100 10 50 50 re B 200 10 50 50 re f 200 100 50 50 re S", "", &[]); + assert_eq!(overprints(&r.document), [(Some(true), Some(true)), (Some(false), Some(true)), (Some(false), None), (None, Some(true))]); + assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings); +} + +#[test] +fn saved_states_and_forms_keep_their_own_overprint() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 400 400]", "200 10 50 50 re f /Off gs 200 100 50 50 re f"); + let x = first_extra(1); + let r = open( + "0 0 1 rg q /On gs 10 10 50 50 re f Q 100 10 50 50 re f /On gs /X0 Do 300 10 50 50 re f", + &format!("/XObject << /X0 {x} 0 R >>"), + &[&form], + ); + // In `q … Q`; after it; in the form (the page's state, then its own); after the form. + assert_eq!(overprints(&r.document).iter().map(|o| o.0).collect::>(), [true, false, true, false, true].map(Some)); +} + +#[test] +fn overprint_mode_0_is_reported() { + // No `/OPM`: mode 0, where a DeviceCMYK colour's zero components knock out. + let r = open("/Zero gs 0 0 0 1 k 10 10 50 50 re f", "", &[]); + assert_eq!(overprints(&r.document), [(Some(true), None)]); + assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings); + assert!(about_overprint(&r)[0].contains("/OPM 0")); + // Nothing overprints: nothing to say. + assert!(about_overprint(&open("0 0 0 1 k 10 10 50 50 re f", "", &[])).is_empty()); +} + +const FONT: &str = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>"; + +#[test] +fn text_overprints_as_its_graphics_state_says() { + // Type or its outlines, as the fonts of the machine allow. + let r = open("0 0 1 rg 10 300 50 50 re f /On gs BT /F1 36 Tf 40 200 Td (Ink) Tj ET /Off gs 100 300 50 50 re f", FONT, &[]); + assert_eq!(overprints(&r.document).iter().map(|o| o.0).collect::>(), [false, true, false].map(Some), "{:?}", r.warnings); + assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings); +} + +#[test] +fn text_of_two_overprint_states_between_two_paths_knocks_out_with_a_warning() { + let r = open("0 0 1 rg BT /F1 36 Tf 40 200 Td /On gs (Ink) Tj /Off gs 0 -50 Td (Paper) Tj ET", FONT, &[]); + assert!(overprints(&r.document).iter().all(|o| o.0 == Some(false)), "{:?}", overprints(&r.document)); + assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings); +} + +#[test] +fn a_page_whose_paths_cant_be_counted_knocks_out_with_a_warning() { + // A shading the interpreter can't read draws nothing: the paths after it would take the + // overprint of the ones before them. + let r = open("0 0 1 rg /Sh0 sh /On gs 10 10 50 50 re f /Off gs 100 10 50 50 re f", "/Shading << /Sh0 << /ShadingType 99 >> >>", &[]); + assert_eq!(overprints(&r.document), [(Some(false), None), (Some(false), None)]); + assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings); +} + +#[test] +fn hidden_optional_content_and_pattern_cells_dont_shift_the_count() { + let ocg = "<< /Type /OCG /Name (Hidden) >>"; + let cell = stream( + "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", + "1 0 0 rg 0 0 5 5 re f 5 5 5 5 re f", + ); + let x = first_extra(1); + let page = PdfPage { + resources: format!("{ON} /Properties << /MC0 {x} 0 R >> /Pattern << /P0 {} 0 R >>", x + 1), + ..PdfPage::new( + 400.0, + 400.0, + "/OC /MC0 BDC 0 0 1 rg 10 10 50 50 re f EMC /Pattern cs /P0 scn 100 10 50 50 re f /On gs 0 0 1 rg 200 10 50 50 re f", + ) + }; + let catalog = format!("/OCProperties << /OCGs [{x} 0 R] /D << /OFF [{x} 0 R] >> >>"); + let bytes = vectorcraft_testkit::pdf::pdf_with_catalog(&[page], &[ocg, &cell], &catalog, None); + for layers in [false, true] { + let r = import_with_report(&bytes, &ImportOptions { layers, ..Default::default() }).unwrap(); + let on: Vec<_> = overprints(&r.document).into_iter().filter(|o| o.0 == Some(true)).collect(); + assert_eq!(on.len(), 1, "layers {layers}: {:?} {:?}", overprints(&r.document), r.warnings); + assert!(about_overprint(&r).is_empty(), "layers {layers}: {:?}", r.warnings); + } +} diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index bface79d..f8474208 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -265,6 +265,8 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp h_scale: 100.0, fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))), stroke: None, + overprint_fill: false, + overprint_stroke: false, }; let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 }; let mut line = TextLine::new(at(100.0, 100.0), 1.0); @@ -288,6 +290,8 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() { h_scale: 100.0, fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))), stroke: None, + overprint_fill: false, + overprint_stroke: false, }; // Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them. let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 }; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 3ba7c5db..1ea8f6f5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public @@ -56,7 +56,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible only | see above; no real-file corpus | | Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | | -| PDF | open, place, save | yes (layers, masks, editable text, security; 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+) | fuzzed import; PDF text placement issues in some files (#722) | +| PDF | open, place, save | yes (layers, masks, editable text, security, overprint; 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+) | fuzzed import; PDF text placement issues in some files (#722) | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505 | | PostScript `.ps` | open | no | via Print to PostScript | | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests` | @@ -83,6 +83,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | PDF and `.ai` import, and placed PDFs, keep overprint: the graphics state's `/OP` and `/op` (ISO 32000-1 §8.6.7) become the overprint of fills, strokes and type, so a spot colour set to overprint still does after export; `/OPM 0` is reported in a note (#1388); scores unchanged | | 2026-10-12 | minor | `.ai` editing data: a gradient with middle stops runs its whole ramp (#1390); type of objects with an appearance, of hidden layers and on later artboards goes to its own text object (#1391); area type in a frame too short for the fallback font shows its first line (#1392); scores unchanged | | 2026-10-12 | minor | `.ai` editing data: placed (linked) files come in as linked images showing the preview the file keeps, so a file with them opens through its layers, with its guides (#1264); scores unchanged | | 2026-10-12 | minor | PDF and `.ai` import: a shape painted with a gradient opens as one path with the gradient (#1304). PDF and `.ai` export: the constant alpha soft mask of a CMYK page with transparency is white (#1286, in part) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 78f5bd30..31b5946d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -490,7 +490,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). @@ -519,6 +519,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | File formats: PDF and `.ai` import and placed PDFs keep overprint (`/OP`, `/op`), which exports again (#1388); own-fixture tests; scores and hours unchanged (estimated) | | 2026-10-12 | minor | File formats: `.ai` editing data follow-ups to #1264: gradients with middle stops (#1390), type of objects with an appearance, hidden layers and later artboards (#1391), area type in a frame too short for the fallback font (#1392); a PDF saved with its editing data opens with its bleed (#1379); score and hours unchanged (estimated) | | 2026-10-12 | minor | UI chrome: dialogs take the keyboard focus on every open (#1367) and give it to a first field that starts greyed out once it is enabled (#1395); Artboards: art flush with an artboard within a rounding error moves, copies and scales with it (#1375, from #1295); Type core: a character no font had is looked for again after a font rescan (#1376, from #992); scores and hours unchanged (estimated) | | 2026-10-12 | minor | Performance: the art placed for symbol instances and inline graphics is kept between frames (#1386; the contributor measured 51.6 → 32.3 ms a frame on 2,000 instances at 2880 × 1800, not re-measured here); budgets still unverified, score unchanged. Automation: `--control` without a valid port stops the desktop app with an error (#1383) | From 7f5d8a3db1dc61b565a6955df1b60a22fea64fa6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 19:34:49 +0700 Subject: [PATCH 13/13] Fix #1387: a .ai whose PDF part is only text opens from it --- crates/engine/src/cmd/fileio/load.rs | 7 ++- crates/engine/src/cmd/fileio/pdfimport.rs | 7 ++- .../engine/src/cmd/fileio/tests_aiimport.rs | 46 +++++++++++++++++++ crates/pdf/src/import.rs | 14 +++--- crates/pdf/src/lib.rs | 3 ++ crates/pdf/src/pages.rs | 8 ++++ crates/testkit/src/ai.rs | 16 +++++-- docs/file-format-parity.md | 5 +- 8 files changed, 88 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 09afbb74..1ff18eee 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -222,7 +222,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result .flatten() .map(|e| (e.intact, move || Some(e.data))); restore_or_import(editing, || match super::pdfimport::import(bytes, opts) { - Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)), + Ok(r) => { + // Pages of text alone: the editing copy alone where it reads, as for a file + // saved without its PDF part (#1387: else the page, which is the art). + let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok()); + Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings))) + } // Saved without its PDF part: the editing copy is all the file has. Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => { from_editing_data_alone(bytes, opts).unwrap_or(Err(e)) diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 1ece0eda..466e14c9 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -130,10 +130,9 @@ fn choice(cmd: &str, key: &str, id: Option<&str>) -> Result .ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", ")))) } -/// Import the pages `o` pick → the document and import notes. -pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec)> { - let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?; - Ok((r.document, r.warnings)) +/// Import the pages `o` pick → the document, import notes and what else the import tells. +pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result { + vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err) } /// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 6012c27e..d885215c 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -154,6 +154,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() { assert!(e.contains("placeholder"), "{e}"); } +/// A page of text alone, as text-only art has in its PDF part: no path is painted. +const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET"; + +/// The kinds of what the first layer holds: type (or its outlines, without the font) and others. +fn text_and_others(s: &Session) -> (usize, usize) { + let art = s.doc().unwrap().doc.layers[0].children().unwrap(); + let text = art.iter().filter(|n| matches!(n.kind, NodeKind::Text(_)) || n.name.as_deref() == Some("")).count(); + (text, art.len() - text) +} + +#[test] +fn an_ai_file_whose_pdf_part_is_only_text_opens_from_it_when_its_editing_data_cant_be_read() { + // #1387. The page states where its art is (an art, trim or bleed box), which the notice + // page of a file saved without its PDF part has no art to do. + let unreadable = b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0"; + for boxed in ["/ArtBox [0 0 200 100]", "/TrimBox [0 0 200 100]", "/BleedBox [0 0 200 100]"] { + let mut s = Session::new(); + let r = open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({})).unwrap(); + // Then a note on the font, where the machine lacks it. + assert_eq!(r["warnings"][0], json!(vectorcraft_pdf::OFF_ARTBOARD_NOTE), "{boxed}: {r}"); + assert_eq!(text_and_others(&s), (1, 0), "{boxed}"); + // The PDF part alone, as asked. + open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({"editingData": false})).unwrap(); + assert_eq!(text_and_others(&s), (1, 0), "{boxed}"); + } + // The same page without a box is the notice: the file still says why it can't open. + let mut s = Session::new(); + let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({})).unwrap_err().to_string(); + assert!(e.contains("without PDF compatibility") && e.contains("can't be read"), "{e}"); + let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({"editingData": false})).unwrap_err().to_string(); + assert!(e.contains("placeholder"), "{e}"); + // Placed, it is read as its page too. + let r = s.execute("file.new", &json!({})).and_then(|_| { + let data = vectorcraft_format::base64_encode(&ai::ai_with(unreadable, TEXT_ART, "/ArtBox [0 0 200 100]")); + s.execute("file.place", &json!({"name": "text.ai", "dataBase64": data, "link": false})) + }); + assert!(r.is_ok(), "{r:?}"); +} + +#[test] +fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_reads() { + let mut s = Session::new(); + open(&mut s, "art.ai", &ai::ai_with(&compressed(&ai::sample_data()), TEXT_ART, "/ArtBox [0 0 200 100]"), json!({})).unwrap(); + check_sample(&s); +} + #[test] fn editing_data_false_opens_only_what_the_page_prints() { let mut s = Session::new(); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 998b45ca..774d514e 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -196,8 +196,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result = vec![]; let mut group_art: HashMap>> = HashMap::new(); let mut nesting = Nesting::new(); @@ -266,7 +267,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); - placeholder &= ai && only_text(&children); + only_text &= ai && is_only_text(&children); + art_boxes |= crate::pages::states_art(page); let mut right = ab.x1; if opts.crop == CropTo::Bounding && let Some(art) = vectorcraft_doc::live::nodes_bounds(&children) @@ -308,7 +310,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result, Vec>)>, page: Rect) } /// Is this art text and nothing else (in groups and clips), with some text? -fn only_text(nodes: &[Arc]) -> bool { +fn is_only_text(nodes: &[Arc]) -> bool { fn walk(nodes: &[Arc], text: &mut bool) -> bool { nodes.iter().all(|n| match &n.kind { NodeKind::Path { clipping: true, .. } => true, diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b45e084d..287ad0e5 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -163,6 +163,9 @@ pub struct ImportReport { pub warnings: Vec, /// The native document the PDF carries (written with Preserve Editing), if any. pub native: Option, + /// Every page shows only text, over an editor's private data: that data, where it reads, + /// is the file's art (a file saved without its PDF part shows a notice there instead). + pub only_text: bool, } /// Result of an export with non-fatal warnings (features approximated or dropped). diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index a23b2630..5a98c40c 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -197,6 +197,14 @@ pub(crate) fn has_private_data(page: &Page<'_>) -> bool { piece_info_has(page, &[b"AIPrivateData"]) } +/// Does `page` state where its art is: an art, trim or bleed box (ISO 32000-1 §14.11.2, the +/// extent of the page's meaningful content and of the finished page)? A page written from an +/// artboard has them; the notice a file saved without its PDF part shows instead of its art is +/// the same page whatever the document, with no art to state the place of. +pub(crate) fn states_art(page: &Page<'_>) -> bool { + [&b"ArtBox"[..], b"TrimBox", b"BleedBox"].iter().any(|k| page.raw().get::<[f32; 4]>(*k).is_some()) +} + /// Does `page`'s `PieceInfo` hold a key starting with one of `prefixes`, up to four dictionaries in? fn piece_info_has(page: &Page<'_>, prefixes: &[&[u8]]) -> bool { fn walk(d: &Dict<'_>, prefixes: &[&[u8]], depth: u32) -> bool { diff --git a/crates/testkit/src/ai.rs b/crates/testkit/src/ai.rs index e8daf194..a999a13e 100644 --- a/crates/testkit/src/ai.rs +++ b/crates/testkit/src/ai.rs @@ -104,7 +104,12 @@ pub fn eps(data: &[u8], page: &str) -> Vec { /// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in /// two streams. pub fn ai(private: &[u8], page: &str) -> Vec { - with_private(private, page, "AIPrivateData") + ai_with(private, page, "") +} + +/// [`ai`] with more entries in the page dictionary (`/ArtBox [0 0 200 100]`). +pub fn ai_with(private: &[u8], page: &str, entries: &str) -> Vec { + with_private(private, page, "AIPrivateData", entries) } /// A PDF saved with Illustrator's editing data: [`ai`]'s file with the two streams under @@ -112,11 +117,12 @@ pub fn ai(private: &[u8], page: &str) -> Vec { /// #868, and nothing else of that file is used; ExifTool's PDF tag table lists them too /// (). pub fn pdf(private: &[u8], page: &str) -> Vec { - with_private(private, page, "AIPDFPrivateData") + with_private(private, page, "AIPDFPrivateData", "") } -/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2`. -fn with_private(private: &[u8], page: &str, key: &str) -> Vec { +/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2` and +/// `entries` more in the page dictionary. +fn with_private(private: &[u8], page: &str, key: &str, entries: &str) -> Vec { let (first, second) = private.split_at(private.len() / 2); let stream = |part: &[u8]| { let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes(); @@ -128,7 +134,7 @@ fn with_private(private: &[u8], page: &str, key: &str) -> Vec { b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \ + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] {entries} /Contents 4 0 R \ /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \ /PieceInfo << /{PIECE} << /Private << /{key}1 5 0 R /{key}2 6 0 R /NumBlock 2 >> >> >> >>" ) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1ea8f6f5..4aa97bd1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public @@ -47,7 +47,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -83,6 +83,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A `.ai` whose PDF part draws only text opens from that part when its editing data can't be read, instead of being refused as saved without PDF compatibility: a page that states an art, trim or bleed box is art, not the placeholder (#1387); scores unchanged | | 2026-10-12 | minor | PDF and `.ai` import, and placed PDFs, keep overprint: the graphics state's `/OP` and `/op` (ISO 32000-1 §8.6.7) become the overprint of fills, strokes and type, so a spot colour set to overprint still does after export; `/OPM 0` is reported in a note (#1388); scores unchanged | | 2026-10-12 | minor | `.ai` editing data: a gradient with middle stops runs its whole ramp (#1390); type of objects with an appearance, of hidden layers and on later artboards goes to its own text object (#1391); area type in a frame too short for the fallback font shows its first line (#1392); scores unchanged | | 2026-10-12 | minor | `.ai` editing data: placed (linked) files come in as linked images showing the preview the file keeps, so a file with them opens through its layers, with its guides (#1264); scores unchanged |