From b67a08dc0a41996578672184022777249ef10561 Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:29:09 +0100 Subject: [PATCH 01/89] Pencil: shows where a stroke continues, connects and closes The continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears an open end of another selected path it connects to. The straight-line cursor becomes a short level line. A stroke continuing a path, a straight line too, shows the closing ring within Close Paths When Ends Are Within of the path's other end, and the release closes it; path.freehand takes the tool's closed for extend instead of its own four-Fidelity distance. --- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/draw2/freehand.rs | 19 ++++-- crates/engine/src/tests/draw2.rs | 25 ++++++++ crates/tools/src/cursor.rs | 12 ++-- crates/tools/src/cursors.toml | 10 +++- crates/tools/src/draw2/gesture.rs | 77 +++++++++++++++++++----- crates/tools/src/draw2/gesture/tests.rs | 80 ++++++++++++++++++++++--- crates/ui-egui/src/cursors.rs | 12 ++-- crates/ui-egui/src/cursors/tests.rs | 17 ++++-- docs/mcp.md | 11 +++- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 5 +- 12 files changed, 217 insertions(+), 58 deletions(-) diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 400e13baab..e6f88a31e2 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { "Pencil", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end, and closes it when `closed` is true (left out, when the stroke ends within four Fidelities of the path's other end, 6 to 400 pt); with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", has_doc, freehand ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 31e184e195..1ec591f2ff 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -204,11 +204,18 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { } None => None, }; - // An extending stroke that ends within four Fidelities of the path's other end closes it: - // at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. + // `closed`, when given, says whether the stroke closes the path: the Pencil and the + // Paintbrush send it, by their Close Paths When Ends Are Within and Alt at the release. + // Left out, an extending stroke that ends within four Fidelities of the path's other end + // closes it: at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. let close_tol = (tol * 4.0).clamp(6.0, 400.0); - let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); - if closing && let Some(l) = pts.last_mut() { + let near_first = pts.last().is_some_and(|p| p.distance(first) <= close_tol); + let wants_close = if p.get("closed").is_some_and(|v| !v.is_null()) { closed } else { near_first }; + let closing = connect.is_none() && sp.anchors.len() >= 2 && wants_close; + // A stroke ending near the other end ends on it; one ending farther away closes with a + // straight segment back to it. + let ends_on_first = closing && near_first; + if ends_on_first && let Some(l) = pts.last_mut() { *l = first; } if let Some((_, _, y, _)) = &connect @@ -225,13 +232,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { add.remove(0); } sp.anchors.extend(add); - if closing + if ends_on_first && sp.anchors.len() > 2 && let Some(l) = sp.anchors.pop() && let Some(a0) = sp.anchors.first_mut() { a0.h_in = l.h_in; sp.closed = true; + } else if closing && !ends_on_first && sp.anchors.len() > 2 { + sp.closed = true; } for a in &mut sp.anchors { *a = Anchor::with_handles(a.p, a.h_in, a.h_out); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index f29df7ff23..89393c0f4c 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -4852,6 +4852,31 @@ fn pencil_extend_closes_within_400_pt_at_any_fidelity() { } } +/// `closed`, when given, decides whether a stroke continuing an open path closes it (the tools +/// send it by Close Paths When Ends Are Within): false leaves it open even ending on the other end; +/// true closes one ending 50 pt away with a straight segment back, the stroke's end kept, and one +/// ending 2 pt away on the other end itself. +#[test] +fn pencil_extend_closes_as_closed_says() { + let mut s = session(); + for (end, closed, ends_on_start) in [([0i32, 0], false, false), ([0, 50], true, false), ([0, 2], true, true)] { + let l = line(&mut s, 0.0, 0.0, 200.0, 0.0); + let points = json!([[200, 0], [200, 100], [100, 150], end]); + let p = json!({"points": points, "fidelity": 1.5, "closed": closed, "extend": {"id": l.0}, "keepSelected": false}); + s.execute("path.freehand", &p).unwrap(); + let pd = path(&s, l); + let sp = &pd.subpaths[0]; + assert_eq!(sp.closed, closed, "ending at {end:?}"); + if closed { + let stroke_end = Point::new(f64::from(end[0]), f64::from(end[1])); + let kept = sp.anchors.iter().any(|a| a.p.distance(stroke_end) < 1e-6); + assert_eq!(kept, !ends_on_start, "ending at {end:?}: {:?}", sp.anchors); + let on_start = sp.anchors.iter().filter(|a| a.p.distance(Point::new(0.0, 0.0)) < 1e-6).count(); + assert_eq!((sp.anchors[0].p, on_start), (Point::new(0.0, 0.0), 1), "ending at {end:?}"); + } + } +} + /// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never /// crash, and what they draw has finite anchors. #[test] diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 584149da8f..85b9cf258e 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -110,14 +110,16 @@ pub enum Cursor { /// The Pencil tool with Alt pressed after the drag starts and Alt key toggles to Smooth Tool off: /// releasing the mouse button with Alt still held closes the path. A drag started with Shift or /// Alt held draws an open straight line instead. Also while a freehand stroke is drawn back near - /// its start, where releasing the mouse button closes the path. A crosshair with a ring. + /// its start, or a stroke continuing a selected path near that path's other end, where + /// releasing the mouse button closes the path. A crosshair with a ring. PencilClose, /// The Pencil tool before a press that draws a straight line (Shift or Alt held). A crosshair with a - /// short straight line. + /// short level line. PencilStraight, - /// The Pencil tool before a press that draws a straight line (Shift or Alt held), over an end of - /// a selected open path: the line continues the path from that end. A crosshair with a short - /// straight line leaving a square end point. + /// The Pencil tool before a press over an end of a selected open path: the stroke, freehand or a + /// straight line, continues the path from that end. Also while a continuing stroke ends near an + /// open end of another path selected at the press, where releasing the mouse button connects the + /// two. A crosshair with a short line rising to the right. PencilContinue, /// The Paintbrush tool. Paintbrush, diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 6f5b32e56a..1c15b37d71 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -638,6 +638,7 @@ group = "Drawing and editing tools" shown_when = [ "The Pencil tool with Alt pressed after the drag starts, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path. A drag started with Shift or Alt held draws an open straight line and does not show this cursor.", "The Pencil tool while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.", + "The Pencil tool while a stroke continuing a selected open path, freehand or a straight line, is drawn to within Close Paths When Ends Are Within of that path's other end: releasing the mouse button there closes the path.", ] hotspot = "The lead tip." look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." @@ -647,14 +648,17 @@ id = "PencilStraight" group = "Drawing and editing tools" shown_when = ["The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone."] hotspot = "The lead tip." -look = "The Pencil cursor with a short straight-line badge, where PencilClose has its ring." +look = "The Pencil cursor with a short level-line badge, where PencilClose has its ring." [[cursor]] id = "PencilContinue" group = "Drawing and editing tools" -shown_when = ["The Pencil tool over an end of a selected open path, within Edit Selected Paths, with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line that continues the path from that end."] +shown_when = [ + "The Pencil tool over an end of a selected open path, within Edit Selected Paths, before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press continues the path from that end, freehand, or with a straight line when Shift or Alt is held.", + "The Pencil tool while a stroke continuing a selected path ends within Edit Selected Paths of an open end of another path selected at the press: releasing the mouse button connects the two.", +] hotspot = "The lead tip." -look = "PencilStraight's badge with a small filled square at the line's lower end (the end point the line continues from)." +look = "The Pencil cursor with a short badge line rising to the right, where PencilClose has its ring (the stroke carries on from the path)." [[cursor]] id = "Paintbrush" diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 2de44f97ba..67da5e3499 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -97,6 +97,8 @@ pub struct GestureTool { near_start: bool, /// Pencil/Paintbrush continuing a selected open path: (path, continue from its start). extend: Option<(NodeId, bool)>, + /// The other end of the path `extend` continues: a release near it closes the path. + close_to: Option, /// A Pencil stroke continuing a selected open path: the other paths selected at the press, /// whose open ends it connects to. connect_to: Vec, @@ -796,6 +798,7 @@ impl GestureTool { began: false, near_start: false, extend: None, + close_to: None, connect_to: vec![], redraw: None, fidelity, @@ -1066,6 +1069,21 @@ impl GestureTool { self.find_end(cx, p, &cx.selection.objects) } + /// The other end of the open end `find_end` found: the opposite end of `id`'s last open + /// subpath. + fn other_end(cx: &ToolContext, id: NodeId, at_start: bool) -> Option { + let pd = cx.doc.node(id)?.path_data()?; + let sp = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty())?; + let a = if at_start { sp.anchors.last() } else { sp.anchors.first() }; + a.map(|a| a.p) + } + + /// Continues the path at the open end `find_extend` found, if any, noting its other end. + fn set_extend(&mut self, cx: &ToolContext, found: Option<(NodeId, bool, Point)>) { + self.extend = found.map(|(id, at_start, _)| (id, at_start)); + self.close_to = found.and_then(|(id, at_start, _)| Self::other_end(cx, id, at_start)); + } + /// [`Self::find_extend`] among `ids`. fn find_end(&self, cx: &ToolContext, p: Point, ids: &[NodeId]) -> Option<(NodeId, bool, Point)> { if self.edit_within <= 0.0 { @@ -1110,16 +1128,32 @@ impl GestureTool { } /// Does a stroke that ends at `end` end near its start (Close Paths When Ends Are Within, off - /// at 0 pixels)? + /// at 0 pixels)? A stroke continuing a selected path ends near that path's other end, a + /// straight line too; a new straight line never closes, as it would run back over itself. fn ends_near_start(&self, cx: &ToolContext, end: Point) -> bool { - self.close_within > 0.0 && self.points.len() > 2 && self.points[0].distance(end) <= cx.tol(self.close_within) + if self.close_within <= 0.0 { + return false; + } + let tol = cx.tol(self.close_within); + match (self.extend, self.close_to) { + (Some(_), Some(to)) => self.points.len() >= 2 && to.distance(end) <= tol, + (Some(_), None) => false, + (None, _) => !self.straight && self.points.len() > 2 && self.points.first().is_some_and(|s| s.distance(end) <= tol), + } } - /// Does a release now, with the stroke ending at `end`, close a freehand stroke because it ends - /// near its start (`finish`)? A stroke that redraws a selected path closes only when the engine - /// finds it does not end on that path, so it counts as not closing. + /// The open end of another path selected at the press that a continuing Pencil stroke ending + /// at `end` connects to (`finish`). + fn connects_at(&self, cx: &ToolContext, end: Point) -> Option<(NodeId, bool, Point)> { + self.extend.and_then(|_| self.find_end(cx, end, &self.connect_to)) + } + + /// Does a release now, with the stroke ending at `end`, close the stroke because it ends near + /// its start, or near the other end of the path it continues (`finish`)? A stroke that redraws + /// a selected path closes only when the engine finds it does not end on that path, so it counts + /// as not closing, and a stroke that connects to another path doesn't close. fn release_closes_near_start(&self, cx: &ToolContext, end: Point) -> bool { - self.draws_path() && !self.smoothing && !self.straight && self.extend.is_none() && self.redraw.is_none() && self.ends_near_start(cx, end) + self.draws_path() && !self.smoothing && self.redraw.is_none() && self.ends_near_start(cx, end) && self.connects_at(cx, end).is_none() } /// Parameters of `path.freehand` for the current stroke, fitted at Fidelity on screen. Only the @@ -1160,14 +1194,16 @@ impl GestureTool { if !self.began { return vec![]; } - // Close Paths When Ends Are Within is off at 0 pixels. + // Close Paths When Ends Are Within is off at 0 pixels. A stroke continuing a + // selected path closes it near its other end, a straight line too. let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end)); - // A straight line has two points: closing it would draw it back over itself. - let closed = !self.straight && ((m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || (self.extend.is_none() && near)); - let mut params = self.freehand_params(cx, closed); + // Alt at the release closes a freehand stroke; a new straight line has two points: + // closing it would draw it back over itself. + let closed = (!self.straight && m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || near; // A Pencil stroke that continues a selected path and ends on an open end of another - // path selected at the press connects the two. - let connect = pts.last().and_then(|&end| self.find_end(cx, end, &self.connect_to)); + // path selected at the press connects the two, and doesn't close. + let connect = pts.last().and_then(|&end| self.connects_at(cx, end)); + let mut params = self.freehand_params(cx, closed && connect.is_none()); if let Some((other, at_start, _)) = connect { params["connect"] = json!({"id": other.0, "end": if at_start { "start" } else { "end" }}); } @@ -1241,7 +1277,7 @@ impl Tool for GestureTool { self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && ev.mods.alt); self.unconstrained = self.straight && !ev.mods.shift; let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None }; - self.extend = extend.map(|(id, at_start, _)| (id, at_start)); + self.set_extend(cx, extend); // The stroke's previews select the path it continues, so the others are kept now. self.connect_to = match self.extend { Some((id, _)) if self.id == "pencil" => cx.selection.objects.iter().copied().filter(|o| *o != id).collect(), @@ -1299,6 +1335,8 @@ impl Tool for GestureTool { return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }; } self.points.truncate(1); + // The line ends at `p`, where its end snaps. + end = p; } else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) || self.points.len() >= crate::MAX_STROKE_SAMPLES { // A release here keeps the last point unless it snaps (`Up`), so the ring // follows the point `finish` will test. @@ -1374,6 +1412,7 @@ impl Tool for GestureTool { self.unconstrained = false; self.clear(); self.extend = None; + self.close_to = None; self.connect_to.clear(); self.redraw = None; out @@ -1517,6 +1556,11 @@ impl Tool for GestureTool { if !self.active && self.straight_at_press(m) { return if self.find_extend(cx, p).is_some() { Cursor::PencilContinue } else { Cursor::PencilStraight }; } + // A continuing Pencil stroke that a release would connect to another selected path's open + // end (`finish`) shows that it continues on. + if self.id == "pencil" && self.active && self.points.last().is_some_and(|&end| self.connects_at(cx, end).is_some()) { + return Cursor::PencilContinue; + } // Alt held at the release closes the path (`finish`). // A straight line stays open, so its release has nothing to close, and before a press Alt // gives the Pencil a straight line. @@ -1536,9 +1580,12 @@ impl Tool for GestureTool { "smooth" => Cursor::Smooth, "pathEraser" => Cursor::PathEraser, "join" => Cursor::Join, + // Before a press over an open end of a selected path the Pencil shows that the stroke + // continues it (`PointerKind::Down`). + _ if !self.active && self.find_extend(cx, p).is_some() => Cursor::PencilContinue, // Before a press that draws a new path the Pencil shows its star; over a selected path - // that the press continues from an end or redraws (`PointerKind::Down`), it does not. - _ if !self.active && self.find_extend(cx, p).is_none() && self.find_redraw(cx, p).is_none() => Cursor::PencilNew, + // that the press redraws (`PointerKind::Down`), it does not. + _ if !self.active && self.find_redraw(cx, p).is_none() => Cursor::PencilNew, _ => Cursor::Pencil, } } diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 624adc2e98..65ad5f00e7 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -785,7 +785,7 @@ fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() { } } let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "over an end the stroke continues"); assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws"); assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path"); // While a stroke is drawn, the Pencil's own cursor. @@ -830,7 +830,7 @@ fn pencil_shift_line_continues_a_selected_path_from_its_end() { } /// Before a press over an end of a selected open path, the Pencil shows its continuation cursor -/// exactly when the press would draw a straight line that continues the path; away from the +/// exactly when the press continues the path, freehand or with a straight line; away from the /// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with /// the Paintbrush, it does not. #[test] @@ -851,12 +851,9 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let shift = Mods { shift: true, ..Default::default() }; let alt = Mods { alt: true, ..Default::default() }; let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - // The release's stroke: a straight line that continues the path. - let continues = |acts: &[Vec]| { - acts.last() - .and_then(|a| a.first()) - .is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2))) - }; + // The release's stroke continues the path. + let continues = + |acts: &[Vec]| acts.last().and_then(|a| a.first()).is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some())); for alt_smooth in [false, true] { for m in [Mods::default(), shift, alt, shift_alt] { for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] { @@ -872,7 +869,7 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let end = Point::new(405.0, 303.0); assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue); assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "a freehand stroke continues it too"); assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends"); // While the line is drawn, the Pencil's own cursor. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift)); @@ -889,6 +886,71 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); } +/// A Pencil stroke continuing a selected path shows the continuation cursor while a release would +/// connect it to an open end of another path selected at the press, and the closing ring while a +/// release would close the path because the stroke ends within Close Paths When Ends Are Within +/// of the path's other end, a straight line too; the release does what the cursor showed. +#[test] +fn a_continued_stroke_shows_where_it_connects_or_closes() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, pts: &[(f64, f64)]| { + let id = d.alloc_id(); + let mut els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(pts[0].0, pts[0].1))]; + els.extend(pts[1..].iter().map(|&(x, y)| vectorcraft_geom::PathEl::LineTo(Point::new(x, y)))); + let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + let a = add(&mut d, &[(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)]); + let b = add(&mut d, &[(500.0, 500.0), (600.0, 500.0)]); + let p = paint(); + let mut both = Selection::default(); + both.set([a, b]); + let mut cx = cx(&d, &both, &p); + cx.smart_guides = false; + cx.snap_to_point = false; + let none = Mods::default(); + let shift = Mods { shift: true, ..Default::default() }; + // Runs a stroke from `pts[0]`, giving the cursor at its last point before the release and the + // release's `path.freehand` parameters. + let run = |t: &mut GestureTool, pts: &[(f64, f64)], m: Mods| { + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m)); + for &(x, y) in &pts[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m)); + } + let (x, y) = pts[pts.len() - 1]; + let shown = t.cursor(&cx, Point::new(x, y), m); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + (shown, v.clone()) + }; + let mut t = GestureTool::new("pencil"); + // To the other selected path's start: it connects, and doesn't close. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (450.0, 450.0), (498.0, 502.0)], none); + assert_eq!(shown, Cursor::PencilContinue, "near the other path's end"); + assert_eq!( + (v["extend"].clone(), v["connect"].clone(), v["closed"].clone()), + (json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}), json!(false)) + ); + // Back near the path's own start (10 px from it): the ring, and the release closes it. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none); + assert_eq!(shown, Cursor::PencilClose, "near the path's other end"); + assert_eq!((v["extend"].clone(), v["closed"].clone()), (json!({"id": a.0, "end": "end"}), json!(true))); + assert!(v.get("connect").is_none(), "{v}"); + // Farther than Close Paths When Ends Are Within (15 px): no ring, and it stays open. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (290.0, 330.0)], none); + assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "away from the other end"); + // A straight line from the end to near the start: the ring, and it closes the path. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 350.0), (306.0, 306.0)], shift); + assert_eq!(shown, Cursor::PencilClose, "a straight line near the other end"); + assert_eq!((v["points"].as_array().map(Vec::len), v["closed"].clone()), (Some(2), json!(true))); + // Close Paths When Ends Are Within off: neither ring nor close. + t.set_option("closeWithin", &json!(0.0)); + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none); + assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "at 0 px"); +} + /// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt /// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. #[test] diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index a438a71881..5495d3753c 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -677,18 +677,16 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(3.0, HALO)); ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(1.2, INK)); } - // The Pencil's crosshair with a short straight line where the closing ring goes. + // The Pencil's crosshair with a short level line where the closing ring goes. Cursor::PencilStraight => { crosshair(ink, p); - line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0)); + line(ink, p + vec2(3.55, 8.5), p + vec2(13.45, 8.5)); } - // The straight-line cursor's line leaving a filled square: the end point a line continues from. + // The Pencil's crosshair with a short line rising to the right where the closing ring goes: + // the stroke carries on from the path. Cursor::PencilContinue => { crosshair(ink, p); - // The square first, so its halo does not cover the line. - let end = p + vec2(5.0, 12.0); - poly(ink, [(-2.0, -2.0), (2.0, -2.0), (2.0, 2.0), (-2.0, 2.0)].map(|(x, y)| end + vec2(x, y)).to_vec(), INK, INK); - line(ink, end, p + vec2(12.0, 5.0)); + line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0)); } Cursor::Eyedropper => eyedropper(ink, p), Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)), diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs index ae1947616f..49c932175d 100644 --- a/crates/ui-egui/src/cursors/tests.rs +++ b/crates/ui-egui/src/cursors/tests.rs @@ -244,7 +244,7 @@ fn the_pencil_new_path_cursor_is_the_crosshair_with_a_star() { } } -/// The Pencil's straight-line cursor is the crosshair with a short straight line below right, +/// The Pencil's straight-line cursor is the crosshair with a short level line below right, /// through the point where the closing cursor's ring is hollow. #[test] fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() { @@ -264,15 +264,18 @@ fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() { assert_eq!(at(&cross, 8.5, 8.5)[3], 0, "the crosshair has no badge at {ppp}"); // Along the line, not across it: no ink off its ends. assert!(!inked(at(&img, 5.0, 5.0)) && !inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); + // Level: inked at both ends of it, and not where the continuation cursor's line rises. + assert!(inked(at(&img, 4.5, 8.5)) && inked(at(&img, 12.5, 8.5)), "a level line at {ppp}"); + assert!(!inked(at(&img, 6.0, 11.0)) && !inked(at(&img, 11.0, 6.0)), "not a rising one at {ppp}"); assert_ne!(img, ring, "at {ppp}"); assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); } } -/// The Pencil's continuation cursor is the straight-line cursor with a filled square on the -/// line's lower end, beside the line where the straight-line cursor has no ink. +/// The Pencil's continuation cursor is the crosshair with a short line rising to the right below +/// right, where the straight-line cursor's line is level. #[test] -fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point() { +fn the_pencil_continuation_cursor_is_the_crosshair_with_a_rising_line() { for ppp in [1.0, 2.0] { let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); let img = rasterize(Cursor::PencilContinue, ppp).unwrap(); @@ -285,8 +288,10 @@ fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point( }; let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}"); - assert!(inked(at(&img, 3.5, 13.5)), "the end point's square at {ppp}"); - assert!(!inked(at(&straight, 3.5, 13.5)), "the straight-line cursor has no end point at {ppp}"); + assert!(inked(at(&img, 5.0, 11.0)) && inked(at(&img, 10.0, 6.0)), "the line rises to the right at {ppp}"); + assert!(!inked(at(&straight, 6.0, 11.0)), "the straight-line cursor's line is level at {ppp}"); + assert!(!inked(at(&img, 4.5, 8.5)) && !inked(at(&img, 12.5, 8.5)), "not a level one at {ppp}"); + assert!(!inked(at(&img, 3.5, 13.5)), "no end point square at {ppp}"); assert!(!inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); } diff --git a/docs/mcp.md b/docs/mcp.md index cb5c40501c..e23a46b0b9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # VectorCraft MCP server -> **Last updated:** 2026-11-13 · **Change:** Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. Previously: Save location-refusal error (#6146); existing response shapes retained. +> **Last updated:** 2026-11-13 · **Change:** Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to @@ -3058,8 +3058,12 @@ already walks Image Trace. Short agent entry points: is rejected with an error. The Pencil and the Paintbrush give Fidelity in screen pixels, 0.01 to 100 px, and send it divided by the zoom (0.0313 to 640) and kept within that range, so a stroke is fitted at the same tolerance on screen at every zoom. A stroke - that continues a path (`extend`) closes it when it ends within four times the Fidelity of the path's other end, at - least 6 pt and at most 400 pt. The Blob Brush's `path.blob` Fidelity stays in points, 0.01 to 100 pt. + that continues a path (`extend`) closes it when `closed` is true: the Pencil and the Paintbrush send it when the + stroke ends within Close Paths When Ends Are Within of the path's other end (the cursor shows the closing ring there), + a straight Pencil line too, or with Alt held at the release. The stroke's end then joins the other end when it is + within four times the Fidelity of it (at least 6 pt and at most 400 pt), and a straight segment closes the path from + farther away. Without `closed`, the stroke closes the path when it ends within that four-times-Fidelity distance. The + Blob Brush's `path.blob` Fidelity stays in points, 0.01 to 100 pt. - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), @@ -11327,5 +11331,6 @@ the error text. The interactive fallback applies only to ordinary File > Save, n | Date | Change | |---|---| +| 2026-11-13 | `path.freehand`: `closed`, when given, decides whether a stroke that continues a path (`extend`) closes it; the Pencil and the Paintbrush send it by Close Paths When Ends Are Within. | | 2026-11-13 | Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097). | | 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..f195cab4b2 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-11-13 · **Change:** minor (`.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Pencil continuation, straight-line and closing cursors; scores and estimates unchanged). > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -53,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release (`a_continued_stroke_shows_where_it_connects_or_closes` in `crates/tools/src/draw2/gesture/tests.rs`, `pencil_extend_closes_as_closed_says` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | @@ -1044,7 +1045,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. 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. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band. While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. - With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. + With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short level line), as the page describes. Over an end of a selected open path, where the stroke continues the path, freehand or as a straight line, it shows a path-continuation cursor (a crosshair with a short line rising to the right), as [Extend paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/extend-paths-with-the-pencil-tool.html) describes, and again while a continuing stroke ends near an open end of another selected path, where the release connects the two ([Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html)). A continuing stroke, a straight line too, that ends within Close Paths When Ends Are Within of the path's other end shows the closing cursor, and the release closes the path; Alt held at the release closes it too. - **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. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). 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). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. 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. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. 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). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. 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. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. 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). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. 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). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 974fbb440e..a7adedb22d 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-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release. Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. | @@ -177,7 +178,7 @@ tools, never checked side by side) isn't captured row by row. | Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 | | Several blend spine points picked and dragged together (#5285) | done | with Direct Selection, Shift-click picks more points of a selected blend's spine or takes one away, as on a path; dragging any picked point moves them all, and the key objects on them, in one undo step (`object.blend.spine.moveAnchor {with}`); a marquee doesn't pick spine points yet | | Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | #2267: a click on a handle end retracts that handle and leaves the anchor a corner with the other one; Shift held at the press on an anchor moves it, handles and all (Shift kept down: at 45° steps) instead of pulling out handles | -| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Fidelity is in screen pixels, the same at any zoom, and goes down to 0.01 px in the dialog and the tool, which sends `path.freehand` the value in points at the current zoom (#5729; the Smooth tool keeps 0.05 pt); its slider runs across the Tool Options row on a logarithmic rail from 0.01 to 20 px, the field shows hundredths, and the slider's and the field's arrow keys step hundredths up to 0.1 px, tenths up to 1 px and whole pixels above (Shift ×10) (#3328); Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor while Shift or Alt is held before the press, and a path-continuation cursor over an end of a selected open path, where the line continues the path; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) on, a stroke selects the path it draws or continues and replaces the earlier selection, and off a new stroke leaves nothing selected, the earlier selection included, while a continued or redrawn path is deselected and the rest of the selection stays; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path; a Pencil stroke from an end of one selected open path to an end of another connects the two into one path | +| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Fidelity is in screen pixels, the same at any zoom, and goes down to 0.01 px in the dialog and the tool, which sends `path.freehand` the value in points at the current zoom (#5729; the Smooth tool keeps 0.05 pt); its slider runs across the Tool Options row on a logarithmic rail from 0.01 to 20 px, the field shows hundredths, and the slider's and the field's arrow keys step hundredths up to 0.1 px, tenths up to 1 px and whole pixels above (Shift ×10) (#3328); Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor (a short level line) while Shift or Alt is held before the press, and a path-continuation cursor (a short rising line) over an end of a selected open path, where the stroke continues the path, freehand or straight, and while a continuing stroke ends near an open end of another selected path it connects to; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) on, a stroke selects the path it draws or continues and replaces the earlier selection, and off a new stroke leaves nothing selected, the earlier selection included, while a continued or redrawn path is deselected and the rest of the selection stays; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, or a stroke continuing a selected path (a straight line too) to within it of that path's other end, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path; a Pencil stroke from an end of one selected open path to an end of another connects the two into one path | | Drop Shadow, Outer Glow and Inner Glow: Opacity, the offsets, Blur and Darkness have spinner arrows that step by one (a unit for distances), repeat when held and stop at the fields' ends (#5367, #5368) | done | dialogs::effect test drop_shadow_and_outer_glow_fields_have_spinner_arrows; other effect dialogs' number fields still have none | | Effect dialogs' colour fields: a swatch beside the hex follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills it and Cancel comes back unchanged (#5365) | done | dialogs::effect tests outer_glow_colour_swatch_opens_the_color_picker and drop_shadow_colour_swatch_opens_the_color_picker (Drop Shadow's Color, #5364); the picker previews nothing itself, the effect dialog previews the returned colour | | Join tool scrubbing, Average, Join | done | Join merges the direct-selected anchors lying on one another along a path, as after Average, into one (#1533); the Join tool trims two crossing open paths whose overhanging ends it scrubs over and joins them at the crossing, instead of drawing a segment between their ends (#5844) | From 6b487226aaee8f328907355365eae59cd9cc7781 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 14:28:53 -0800 Subject: [PATCH 02/89] i18n: Japanese has a row for every message the untranslated report lists The Japanese catalog lacked a row for 1,842 of the status and error messages that the untranslated report finds in the sources, so the status bar showed those messages in English. - ja.tsv gains 1,842 `@msg` rows: the errors of commands and their parameter checks, scripts, file import and export, printing and plug-ins. The translations are VectorCraft's own, written from the English meaning with the catalog's conventions and its existing feature names. Placeholders, parameter names, command ids and literal values stay as the English writes them. - The untranslated report now lists no Japanese messages. Menu strings, `tl!` literals and undo labels are unchanged. - Two of the messages have two placeholders in a row. The status bar cannot match such a row, so it does not use those two rows. - The row for `{k} has control characters{_1}` does not match the message for a multi-line File Info field, where the last placeholder is empty. - The Japanese row of docs/localization-parity.md gives the catalog's current message counts. Docs: docs/target-app-parity.md, docs/localization-parity.md. --- crates/ui-egui/src/i18n/ja.tsv | 1842 ++++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 3 files changed, 1844 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 16d873ee32..9b945bd23d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6299,6 +6299,1848 @@ axis Y Axis Y 軸 @msg no menu item that can be inserted runs this command このコマンドを実行する、挿入できるメニュー項目はありません @msg the action already has 10,000 steps アクションにはすでに 10,000 ステップがあります @msg the action set would be larger than 8 MiB アクションセットが 8 MiB を超えます +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions strict モードの関数では 'caller'、'callee'、'arguments' を使用できません +@msg Alignment must be one of the choices 揃えには選択肢のいずれかを指定してください +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} が Boolean ではない値に対して呼び出されました +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB サンプルを CMYK で読み取れなかった CMYK 画像は RGB で読み込みました +@msg Cannot write to the location of “{name}”; choose where to save it 「{name}」の場所に書き込めません。保存先を選択してください +@msg DCS files can only be placed linked: use File › Place with Link on DCS ファイルはリンクとしてのみ配置できます: 「リンク」をオンにして「ファイル › 配置」を使用してください +@msg Date.prototype method called on a value that is not a Date object Date.prototype のメソッドが Date オブジェクトではない値に対して呼び出されました +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString がオブジェクトではない値に対して呼び出されました +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString が関数ではない値に対して呼び出されました +@msg Import SVG Filter reads SVG files, not SVGZ 「SVG フィルターを読み込み」が読み込むのは SVG ファイルで、SVGZ ファイルは読み込みません +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (オフセット {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify は自身を含む構造を書き出せません +@msg Line Gap must be a number 行間には数値を指定してください +@msg Namespace.prototype.toString needs a Namespace Namespace.prototype.toString には Namespace が必要です +@msg New Document profiles need the preferences folder, which this run doesn't have 新規ドキュメントプロファイルには環境設定フォルダーが必要ですが、この実行環境にはありません +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} が数値ではない値に対して呼び出されました +@msg Object.create's prototype must be an object or null Object.create のプロトタイプにはオブジェクトまたは null を指定してください +@msg Object.{function} was given a value that is not an object Object.{function} にオブジェクトではない値が渡されました +@msg QName.prototype.toString needs a QName QName.prototype.toString には QName が必要です +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} が RegExp オブジェクトではない値に対して呼び出されました +@msg Stencil Bridges: the selected art is too large or too detailed to bridge ステンシルブリッジ: 選択したアートが大きすぎるか細かすぎるため、ブリッジを作成できません +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString が文字列ではない値に対して呼び出されました +@msg The {name} profile could not be read, so the new document has the default settings: {reason} {name} プロファイルを読み込めなかったため、新規ドキュメントには初期設定を使用しました: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time トラップ: トラップできるほど近いオブジェクトの組が {_1} 組を超えているか、水平方向の範囲が重なるオブジェクトが多すぎます。一度にトラップするオブジェクトを減らしてください +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off カス取り枠とライン: アートの外側に収まるカス取りラインがなく、カス取り枠もオフになっています +@msg \\ at end of pattern パターンの末尾に \\ があります +@msg `alignOn` cannot be empty `alignOn` を空にすることはできません +@msg `alignOn` must be a string `alignOn` には文字列を指定してください +@msg `align` must be a string `align` には文字列を指定してください +@msg `answer` for this {kind} dialog must be one of: {_1} この {kind} ダイアログの `answer` には次のいずれかを指定してください: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers` の回答が {MAX_ANSWERS} 個を超えています +@msg `answers` is larger than 1 MiB `answers` が 1 MiB を超えています +@msg `answers` must be a list of answers `answers` には回答のリストを指定してください +@msg `artboard` must be the index of an artboard of the document `artboard` にはドキュメントのアートボードのインデックスを指定してください +@msg `artboardsAt` must be [x, y] `artboardsAt` には [x, y] を指定してください +@msg `artboards` names an artboard the document does not have `artboards` にドキュメントにないアートボードが指定されています +@msg `artboards` names an artboard twice `artboards` に同じアートボードが 2 回指定されています +@msg `at` must be [x, y] (got {at}) `at` には [x, y] を指定してください (指定値: {at}) +@msg `at` must be a point [x, y] `at` には点 [x, y] を指定してください +@msg `beside` needs `side` `beside` には `side` が必要です +@msg `code` must be a whole number `code` には整数を指定してください +@msg `colorMode` must be one of rgb, cmyk `colorMode` には rgb、cmyk のいずれかを指定してください +@msg `colors` does not apply to the {name} reduction `colors` は {name} 減色には適用されません +@msg `colors` must be a whole number from 2 to 256 `colors` には 2 から 256 までの整数を指定してください +@msg `colors` must be at least 9 for the uniform reduction 均等減色では `colors` に 9 以上を指定してください +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` には 1 から {MAX_THEME_COLORS} までの値を指定してください (指定値: {n}) +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` には 0 から {MAX_COUNT} までの整数を指定してください +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` には 0 から 100 までの数値を指定してください +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` には {lo}~{hi} pt の値を指定してください +@msg `focal` and `radius` cannot be used together `focal` と `radius` は同時に使用できません +@msg `focal` must be an array of [subpath, anchor] pairs `focal` には [subpath, anchor] の組の配列を指定してください +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` に指定された [{si}, {ai}] はパスのアンカーではありません +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` に指定できるのは null のみで、字形の選択を解除します (選択は text.setGlyphAlternate で行います) +@msg `group` must be one of {_1} `group` には {_1} のいずれかを指定してください +@msg `group` must be the Objects on Path group of `member` and `target` `group` には `member` と `target` のパス上のオブジェクトグループを指定してください +@msg `id` is an object id, not {v} `id` にはオブジェクト ID を指定してください ({v} は指定できません) +@msg `item` needs `index` `item` には `index` が必要です +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` は文字スタイルではなく行の先頭文字の設定です (text.setRangeStyle で設定します) +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` には "pushInFirst"、"pushOutFirst"、"pushOutOnly" のいずれかを指定してください +@msg `leader` must be a string `leader` には文字列を指定してください +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` には 1~{MAX_SUGGESTIONS} を指定してください +@msg `name` must be a name that is not empty `name` には空でない名前を指定してください +@msg `name` must not be empty `name` を空にすることはできません +@msg `origin` must be [x, y] in points `origin` にはポイント単位の [x, y] を指定してください +@msg `picks` must be an array (got {v}) `picks` には配列を指定してください (指定値: {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` には 1~{MAX_PICKS} 個の指定が必要です (指定数: {_1}) +@msg `points` must hold from 2 to 100000 points `points` には 2~100000 個の点が必要です +@msg `radius` must be 0 or more `radius` には 0 以上を指定してください +@msg `rect` must be [x, y, width, height] `rect` には [x, y, width, height] を指定してください +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` には [x0, y0, x1, y1] を指定してください (指定値: {r}) +@msg `replace` must be a string `replace` には文字列を指定してください +@msg `rows` must be lists of numbers or nulls, not {r} `rows` には数値または null のリストを指定してください ({r} は指定できません) +@msg `scale` must be a number from 0.001 to 1000 `scale` には 0.001 から 1000 までの数値を指定してください +@msg `scope.{key}` must be a non-empty array `scope.{key}` には空でない配列を指定してください +@msg `scope` must be "document", {artboards} or {layers} `scope` には "document"、{artboards}、{layers} のいずれかを指定してください +@msg `side` must be "left", "right", "top" or "bottom" `side` には "left"、"right"、"top"、"bottom" のいずれかを指定してください +@msg `side` places the artboard: leave out x, y, width and height `side` でアートボードを配置します: x、y、width、height は省略してください +@msg `source` is the only path in series group `target` `source` は系列グループ `target` の唯一のパスです +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` ではボックス全体のラインが {MAX_COUNT} 本を超えます +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` には 0 より大きく {MAX_DISTANCE} pt 以下の値を指定してください +@msg `target` must be an object of the same Objects on Path group as `member` `target` には `member` と同じパス上のオブジェクトグループのオブジェクトを指定してください +@msg `tolerance` must be from 0 to 100 pt `tolerance` には 0~100 pt の値を指定してください +@msg `version` must be 0 or {_1} `version` には 0 または {_1} を指定してください +@msg `version` must be 0 or {newest} `version` には 0 または {newest} を指定してください +@msg `warichuAlign` must be one of {_1} `warichuAlign` には {_1} のいずれかを指定してください +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` には {_1} から {_2} までの整数を指定してください +@msg `weight` must be from 0.01 to 1000 pt `weight` には 0.01~1000 pt の値を指定してください +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` には行ごとに 1 つずつ、{_1} 個の数値のリストを指定してください (指定値: {v}) +@msg `xml` must be a string `xml` には文字列を指定してください +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}` のエントリ {n} は、{_1} のバイト {start} の段落に、前のエントリとは異なるインデントを指定しています +@msg `{KEY}` entry {n} must be an object `{KEY}` のエントリ {n} にはオブジェクトを指定してください +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}` のエントリ {n} は `leftIndent` も `firstLineIndent` も設定していません +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}` のエントリ {n}: `id` にはオブジェクト ID を指定してください +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}` のエントリ {n}: `start` にはバイトオフセットを指定してください +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}` のエントリ {n}: `{k}` には -{MAX_PARA}~{MAX_PARA} pt の数値を指定してください +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}` のエントリ {n}: {_1} にはバイト {start} から始まる段落がありません +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}` のエントリ {n}: `{k}` は不明なキーです +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}` のエントリ {n}: {_1} はテキストオブジェクトではありません +@msg `{KEY}` is empty `{KEY}` が空です +@msg `{KEY}` must be a list `{KEY}` にはリストを指定してください +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` に指定できるエントリは最大 {MAX_PARAGRAPH_INDENTS} 個です (指定数: {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}` と一緒に `{other}` は指定できません +@msg `{_1}` can't name a composite font `{_1}` は合成フォントの名前に使用できません +@msg `{_1}` is the name of an installed font `{_1}` はインストール済みのフォントの名前です +@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: {_2} は合成フォント `{font}` で設定されています +@msg `{base}` is larger than {_1} MB `{base}` は {_1} MB を超えています +@msg `{base}` is not in the DCS file's folder `{base}` は DCS ファイルのフォルダーにありません +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` の単語が {MAX_EDIT_WORDS} 個を超えています +@msg `{key}` must be 0 or more `{key}` には 0 以上を指定してください +@msg `{key}` must be [x, y] numbers `{key}` には数値の [x, y] を指定してください +@msg `{key}` must be a list of words `{key}` には単語のリストを指定してください +@msg `{key}` must be a list, not {v} `{key}` にはリストを指定してください ({v} は指定できません) +@msg `{key}` must be a number or null, not {v} `{key}` には数値または null を指定してください ({v} は指定できません) +@msg `{key}` must be a whole number of 0 or more `{key}` には 0 以上の整数を指定してください +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` には 0~{MAX_DISTANCE} pt の値を指定してください +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` には {lo}~{hi} の値を指定してください ({v} は指定できません) +@msg `{key}` must be one of auto, always, never `{key}` には auto、always、never のいずれかを指定してください +@msg `{key}` must be one of miter, round, bevel `{key}` には miter、round、bevel のいずれかを指定してください +@msg `{key}` must be one word of at most {_1} characters `{key}` には {_1} 文字以内の 1 語を指定してください +@msg `{key}` must hold single words of at most {_1} characters `{key}` には {_1} 文字以内の単語を 1 語ずつ指定してください +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` にはパス上のオブジェクトグループのパスに配置されたオブジェクトを指定してください +@msg `{key}` names an object that is hidden or locked `{key}` に指定されたオブジェクトは非表示またはロックされています +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` は独立したファイルで、DCS ファイルのパスと同じ場所でのみ見つけられます +@msg `{name}`: give the kanji part `{name}`: 漢字の部分を指定してください +@msg `{name}`: parts must be an object `{name}`: parts にはオブジェクトを指定してください +@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: `{k}` は不明なクラスです。クラスは kanji、kana、punctuation、symbols、roman、numerals です +@msg `{name}`: unknown key `{k}` in {_1} `{name}`: {_1} の `{k}` は不明なキーです +@msg `{name}`: {_1} must be an object `{name}`: {_1} にはオブジェクトを指定してください +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` は合成フォントの名前に使用できません (1~{COMPOSITE_NAME_MAX} 文字、制御文字なし) +@msg `{u}` is no Unicode character `{u}` は Unicode 文字ではありません +@msg `{word}` isn't in the custom dictionary `{word}` はカスタム辞書にありません +@msg a CMap has too many mappings CMap のマッピングが多すぎます +@msg a \\u escape needs four hexadecimal digits \\u エスケープには 4 桁の 16 進数が必要です +@msg a comma in an XPath expression must separate function arguments XPath 式のカンマは関数の引数を区切るものでなければなりません +@msg a document holds at most {_1} SVG filters ドキュメントに含められる SVG フィルターは最大 {_1} 個です +@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts ドキュメントに保持できる合成フォントは最大 {COMPOSITE_FONTS_MAX} 個です +@msg a document with unsaved changes has no recovery copy 未保存の変更があるドキュメントに復帰用のコピーがありません +@msg a font's data is too large フォントのデータが大きすぎます +@msg a font's subroutines are too large フォントのサブルーチンが大きすぎます +@msg a function declaration without a name 名前のない関数宣言です +@msg a host value has too many entries ホストの値のエントリが多すぎます +@msg a host value is nested too deep ホストの値の入れ子が深すぎます +@msg a namespace declaration uses a reserved prefix or URI 名前空間の宣言で予約済みのプレフィックスまたは URI が使用されています +@msg a namespace qualifier is undefined 名前空間の修飾子が定義されていません +@msg a namespace with an empty URI cannot have a prefix URI が空の名前空間にはプレフィックスを付けられません +@msg a number has no digits 数値に数字がありません +@msg a number's exponent has no digits 数値の指数部に数字がありません +@msg a number's fraction has no digits 数値の小数部に数字がありません +@msg a property descriptor cannot have both a value or writable and a get or set プロパティ記述子に value または writable と get または set の両方を指定することはできません +@msg a property descriptor must be an object プロパティ記述子にはオブジェクトを指定してください +@msg a property's {name} must be a function or undefined プロパティの {name} には関数または undefined を指定してください +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied 含まれるストリームをデコードすると 256 MB を超えるか、デコードパラメーターを適用できません +@msg a string holds a control character 文字列に制御文字が含まれています +@msg a string holds an escape sequence JSON does not have 文字列に JSON にないエスケープシーケンスが含まれています +@msg a string is not closed 文字列が閉じていません +@msg an SVG filter is named by its id: give `xml` with `name` SVG フィルターの名前はその ID です: `xml` と `name` を指定してください +@msg an XML element has two attributes with the same name XML 要素に同じ名前の属性が 2 つあります +@msg an XML value cannot be inserted into itself or its descendants XML 値をそれ自身またはその子孫に挿入することはできません +@msg an XML value's children cannot be assigned by position XML 値の子を位置で代入することはできません +@msg an XML value's children cannot be deleted by position XML 値の子を位置で削除することはできません +@msg an XMLList converts to XML only when it has one item XMLList を XML に変換できるのは、項目が 1 つの場合だけです +@msg an XPath node test is missing XPath のノードテストがありません +@msg an XPath operand is missing XPath のオペランドがありません +@msg an XPath operator is missing XPath の演算子がありません +@msg an XPath predicate or step cannot follow here ここに XPath の述語やステップを続けることはできません +@msg an XPath predicate or step follows a value that is not a node-set XPath の述語またはステップがノードセットではない値の後にあります +@msg an XPath step is missing XPath のステップがありません +@msg an image couldn't be decoded and is missing from the picture 画像をデコードできなかったため、図に含まれていません +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) {w} × {h} ピクセルの画像は大きすぎて変換できません (最大 {_1} ピクセル) +@msg apply was called on a value that is not a function apply が関数ではない値に対して呼び出されました +@msg area type in a thread cannot use Dynamic Text: {frames} スレッド内のエリア内文字ではダイナミックテキストを使用できません: {frames} +@msg array literal too long 配列リテラルが長すぎます +@msg assignment to a constant binding 定数バインディングへの代入です +@msg at least one row that isn't excluded needs a weight above 0 除外されていない行の少なくとも 1 つに 0 より大きいウェイトが必要です +@msg bad point {v}: expected [x, y] 点 {v} が正しくありません: [x, y] を指定してください +@msg blend must be a mode name blend には描画モードの名前を指定してください +@msg both sides of an XPath | must select nodes XPath の | の両辺でノードを選択する必要があります +@msg canceled キャンセルしました +@msg cannot convert the object to a primitive value オブジェクトをプリミティブ値に変換できません +@msg cannot convert undefined or null to an object undefined または null をオブジェクトに変換できません +@msg character dimensions cannot be represented with the source text transform 文字の寸法をソースのテキスト変形で表すことができません +@msg character dimensions cannot be represented with the target text transform 文字の寸法をターゲットのテキスト変形で表すことができません +@msg continuation must be an array of path chunks continuation にはパスチャンクの配列を指定してください +@msg continuation requires points continuation には点が必要です +@msg dataBase64 is not base64 dataBase64 が base64 ではありません +@msg each font must be {name, parts} 各フォントは {name, parts} にしてください +@msg each font needs a name 各フォントに名前が必要です +@msg each path chunk must contain 1 to 4096 points 各パスチャンクには 1~4096 個の点が必要です +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) 各指定は {{at: [x, y]}} または {{rect: [x0, y0, x1, y1]}} です (指定値: {v}) +@msg each weight must be a number of 0 or more (got {v}) 各ウェイトには 0 以上の数値を指定してください (指定値: {v}) +@msg finally table {t} out of range finally テーブル {t} が範囲外です +@msg flags cannot be given with a RegExp object RegExp オブジェクトと一緒にフラグを指定することはできません +@msg fonts must be a list of composite fonts fonts には合成フォントのリストを指定してください +@msg function declaration index out of range 関数宣言のインデックスが範囲外です +@msg give `char`, `text`, `code` or `unicode` `char`、`text`、`code`、`unicode` のいずれかを指定してください +@msg give `count` or `spacing`, not both `count` または `spacing` のどちらか一方を指定してください +@msg give `name`, an SVG filter's name SVG フィルターの名前を `name` に指定してください +@msg give `path`, an SVG file, or `svg`, SVG text SVG ファイルを `path` に、または SVG テキストを `svg` に指定してください +@msg give `xml`, one SVG element SVG の 要素を 1 つ `xml` に指定してください +@msg give exactly one of `at`, `rect` and `points` `at`、`rect`、`points` のうち 1 つだけを指定してください +@msg give exactly one of `path`, `data` and `dataBase64` `path`、`data`、`dataBase64` のうち 1 つだけを指定してください +@msg give fonts: [{name, parts}…] fonts: [{name, parts}…] を指定してください +@msg give index or indexes, not both index または indexes のどちらか一方を指定してください +@msg give one element 要素を 1 つ指定してください +@msg give one of path, data or dataBase64 path、data、dataBase64 のいずれかを指定してください +@msg give opacity or blend opacity または blend を指定してください +@msg handler at {_1} expects {_2} values but its range starts with {d} {_1} のハンドラーは {_2} 個の値を想定していますが、範囲の開始時は {d} 個です +@msg handler depth too large ハンドラーの深さが大きすぎます +@msg handler range {_1}..{_2} out of range ハンドラーの範囲 {_1}..{_2} が範囲外です +@msg hiddenLayers must be true or false hiddenLayers には true または false を指定してください +@msg hvcC hvcC +@msg images in a spot ink whose samples couldn't be read were imported in RGB サンプルを読み取れなかった特色インキの画像は RGB で読み込みました +@msg images past the most pixels one file may hold were left out 1 つのファイルに含められる最大ピクセル数を超えた画像は除外しました +@msg images too large to read were left out 大きすぎて読み込めない画像は除外しました +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes に指定できるエントリは最大 {MAX_GUIDE_INDEXES} 個です +@msg indexes must be an array of guide indexes indexes にはガイドのインデックスの配列を指定してください +@msg instruction out of range 命令が範囲外です +@msg instruction {pc} ({op}) pops below the stack 命令 {pc} ({op}) がスタックの底を超えて取り出しています +@msg instruction {pc} reached with stack depths {seen} and {d} 命令 {pc} にスタックの深さ {seen} と {d} で到達しました +@msg invalid array length 配列の長さが無効です +@msg invalid group グループが無効です +@msg invalid object id {v}: expected a non-negative integer オブジェクト ID {v} が無効です: 0 以上の整数を指定してください +@msg invalid regular expression flag '{_1}' 正規表現のフラグ '{_1}' が無効です +@msg invalid regular expression: {m} 正規表現が無効です: {m} +@msg invalid time value 時刻の値が無効です +@msg is a confirm's answer, whose `value` must be true or false は confirm の回答で、`value` には true または false を指定してください +@msg is a prompt's answer, whose `value` must be a string or null は prompt の回答で、`value` には文字列または null を指定してください +@msg is an alert's answer, whose `value` must be "ok" は alert の回答で、`value` には "ok" を指定してください +@msg is longer than 16 KiB が 16 KiB を超えています +@msg it draws its image through a clipping path クリッピングパスを通して画像を描画しています +@msg it draws no image 画像を描画していません +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes {MAX_DICTIONARY_FILE_BYTES} バイトを超えています +@msg it isn't a file ファイルではありません +@msg it names {_1} plates, and at most {MAX_PLATES} are read {_1} 個の版が指定されていますが、読み込めるのは最大 {MAX_PLATES} 個です +@msg its art is too long or too detailed for a head アートが長すぎるか細かすぎるため、矢印にできません +@msg its art paints nothing アートが何も描画しません +@msg its image is too large to read 画像が大きすぎて読み込めません +@msg its plates are too large: more than {MAX_SAMPLES} pixels together 版が大きすぎます: 合計で {MAX_SAMPLES} ピクセルを超えています +@msg its plates are too large: {_1} plates of {w} × {h} pixels 版が大きすぎます: {w} × {h} ピクセルの版が {_1} 個あります +@msg its plates can't be kept together 版をまとめて保持できません +@msg its shapes could not be combined into one シェイプを 1 つに結合できませんでした +@msg jump to {pc}, past the end 末尾を超えた {pc} へのジャンプです +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding には {_1} のいずれかを指定してください +@msg localize: the first argument must be a localization object or a string localize: 最初の引数にはローカライズオブジェクトまたは文字列を指定してください +@msg missing `picks` `picks` がありません +@msg missing `word` `word` がありません +@msg must be an object with `kind` and `value` には `kind` と `value` を持つオブジェクトを指定してください +@msg must have `kind` alert, confirm or prompt の `kind` には alert、confirm、prompt のいずれかを指定してください +@msg must have exactly `kind` and `value` には `kind` と `value` だけを指定してください +@msg names must be a list of names names には名前のリストを指定してください +@msg names must be strings names には文字列を指定してください +@msg needs a closing quote after the name 名前の後に閉じ引用符が必要です +@msg needs a name 名前が必要です +@msg needs a name of at most 64 bytes 64 バイト以内の名前が必要です +@msg needs a name of letters, digits and _, or a quoted name 文字、数字、_ からなる名前、または引用符で囲んだ名前が必要です +@msg needs a name without control characters 制御文字を含まない名前が必要です +@msg nesting too deep 入れ子が深すぎます +@msg no HP-GL format HP-GL 形式はありません +@msg no New Document profile called `{shown}` ({_1}) `{shown}` という新規ドキュメントプロファイルはありません ({_1}) +@msg no SVG filter at index {k} インデックス {k} に SVG フィルターはありません +@msg no arrowheads file here ここに矢印ファイルはありません +@msg no character has code {code} コード {code} の文字はありません +@msg no composite font 合成フォントがありません +@msg no composite font named `{n}` `{n}` という名前の合成フォントはありません +@msg no composite fonts to export 書き出す合成フォントがありません +@msg no custom arrowhead {name} (see stroke.arrowheads.list) カスタム矢印 {name} はありません (stroke.arrowheads.list を参照) +@msg no file to open 開くファイルがありません +@msg no instructions 命令がありません +@msg no layer to rename 名前を変更するレイヤーがありません +@msg no random numbers ({e}) 乱数を取得できません ({e}) +@msg no selected or given object has a visible 3D effect 選択または指定したオブジェクトに、表示されている 3D 効果がありません +@msg no such artboard {v} アートボード {v} はありません +@msg no symbol named `{_1}` `{_1}` という名前のシンボルはありません +@msg node {_1} already has the text variable `{_2}` ノード {_1} にはすでにテキスト変数 `{_2}` があります +@msg not a DCS file DCS ファイルではありません +@msg not a RegExp object RegExp オブジェクトではありません +@msg not a composite fonts file: no compositeFonts list 合成フォントファイルではありません: compositeFonts リストがありません +@msg not a composite fonts file: {e} 合成フォントファイルではありません: {e} +@msg not a regular expression 正規表現ではありません +@msg not an action set: the text doesn't start with /version and a number アクションセットではありません: テキストが /version と数値で始まっていません +@msg nothing to repeat 繰り返す対象がありません +@msg numbers out of order in {_1} quantifier {_1} 量指定子の数値の順序が正しくありません +@msg object {_1} needs a 3D effect オブジェクト {_1} には 3D 効果が必要です +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic オブジェクト {_1}: 中空の「押し出しとベベル」にはキャップがありません: グラフィックをマッピングするには「キャップ」をオンにしてください +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front オブジェクト {_1}: 奥行きが 0 のオブジェクトは面が 1 つです: グラフィックは前面にマッピングしてください +@msg object {_1}: {_2} オブジェクト {_1}: {_2} +@msg object {_1}: {e} オブジェクト {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams オブジェクト {_1}: インデックス {index} の {name} には照明プリセットがありません。ライトは effect.setParams で設定してください +@msg opacity must be a number opacity には数値を指定してください +@msg operand too large オペランドが大きすぎます +@msg order is a whole number from 0 to {others}, not `{v}` order には 0 から {others} までの整数を指定してください (`{v}` は指定できません) +@msg path chunks must share their boundary point パスの分割部分は境界点を共有している必要があります +@msg path is empty パスが空です +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false の場合、useArtboards が false のときは、編集可能なアートワークを保持するためにアートボードが必要です +@msg point x must be a number 点の x には数値を指定してください +@msg point y must be a number 点の y には数値を指定してください +@msg points must be [x,y] pairs points には [x,y] の組を指定してください +@msg points must be finite canvas points points にはキャンバス上の有限の点を指定してください +@msg range out of order in character class 文字クラス内の範囲の順序が正しくありません +@msg reduce of an empty array with no initial value 初期値のない空の配列に対する reduce です +@msg regular expression flag '{_1}' given twice 正規表現のフラグ '{_1}' が 2 回指定されています +@msg sampled text would exceed the canvas (coordinates out of range) サンプリングしたテキストがキャンバスを超えます (座標が範囲外) +@msg select an SVG filter SVG フィルターを選択してください +@msg select an object with a 3D effect 3D 効果のあるオブジェクトを選択してください +@msg select an object with a 3D effect, or give its id in ids 3D 効果のあるオブジェクトを選択するか、その ID を ids に指定してください +@msg select one layer, group or object to rename 名前を変更するレイヤー、グループ、オブジェクトのいずれかを 1 つ選択してください +@msg select the art to weed カス取りするアートを選択してください +@msg select type on a path or area type パス上文字またはエリア内文字を選択してください +@msg side is left or right, not `{v}` side には left または right を指定してください (`{v}` は指定できません) +@msg source text transform is collapsed or unrepresentable 元のテキストの変形がつぶれているか、表現できません +@msg table must be true or false table には true または false を指定してください +@msg takes one name 名前を 1 つ指定してください +@msg target text transform is collapsed or unrepresentable 対象のテキストの変形がつぶれているか、表現できません +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer 分割数 (最大 100000) またはダッシュの段数が多くなりすぎます: 破線パターンを単純にするか、ダッシュを長くしてください +@msg the Layers panel is not shown レイヤーパネルが表示されていません +@msg the Layers panel's search, filter or Show Layers Only hides its row レイヤーパネルの検索、フィルター、「レイヤーのみを表示」のいずれかでその行が隠れています +@msg the Objects on Path group is hidden or locked 「パス上のオブジェクト」グループが非表示またはロックされています +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes SVG ファイルが {MAX_IMPORT_BYTES} バイトを超えています +@msg the XML operation needs an XML or XMLList value XML 操作には XML または XMLList の値が必要です +@msg the XPath expression does not evaluate to a node-set XPath 式の評価結果がノードセットではありません +@msg the XPath expression ends early XPath 式が途中で終わっています +@msg the XPath expression has an unmatched ) XPath 式に対応していない ) があります +@msg the XPath expression has an unmatched ] XPath 式に対応していない ] があります +@msg the XPath expression has an unmatched closing bracket XPath 式に対応していない閉じ括弧があります +@msg the XPath expression selects a namespace node, which is not an XML value XPath 式が名前空間ノードを選択していますが、これは XML の値ではありません +@msg the XPath expression selects the root node, which is not an XML value XPath 式がルートノードを選択していますが、これは XML の値ではありません +@msg the XPath function {name} needs a node-set XPath 関数 {name} にはノードセットが必要です +@msg the XPath function {name} takes the wrong number of arguments XPath 関数 {name} の引数の数が正しくありません +@msg the XPath name functions need a node-set XPath の名前関数にはノードセットが必要です +@msg the XPath prefix {shown} is not bound to a namespace XPath の接頭辞 {shown} は名前空間に結び付けられていません +@msg the XPath variable ${shown} is not bound XPath 変数 ${shown} は束縛されていません +@msg the argument list is not an object 引数リストがオブジェクトではありません +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out アートワークが EPS には大きすぎます: PostScript の 256 MB より後の部分は除外しました +@msg the bitmaps hold more pixels than one file may ビットマップのピクセル数が 1 つのファイルに含められる数を超えています +@msg the code runs past its last instruction コードが最後の命令を超えて実行されます +@msg the curve tolerance must be from 0.01 to 10 points 曲線の許容値には 0.01 から 10 ポイントまでの値を指定してください +@msg the custom dictionary couldn't be saved to {_1}: {e} カスタム辞書を {_1} に保存できませんでした: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is カスタム辞書 {_1} を読み込めないため ({why})、そのまま残しました +@msg the document already has an SVG filter named {name} ドキュメントにはすでに {name} という名前の SVG フィルターがあります +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it ドキュメントには {name} という名前の別の SVG フィルターがあります: svgFilter.apply を使用するか、編集するには `replace` を付けて svgFilter.define を使用してください +@msg the document has no SVG filter named {name} ドキュメントに {name} という名前の SVG フィルターはありません +@msg the document has no SVG filter named {r} ドキュメントに {r} という名前の SVG フィルターはありません +@msg the document has no color swatches ドキュメントにカラースウォッチがありません +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 ドキュメントのスウォッチには {_1} 色ありますが、ドキュメント減色で使用できるのは最大 255 色です +@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts ファイルに {COMPOSITE_FONTS_MAX} 個を超える合成フォントがあります +@msg the file holds no composite fonts ファイルに合成フォントがありません +@msg the file holds two composite fonts named `{_1}` ファイルに `{_1}` という名前の合成フォントが 2 つあります +@msg the file is larger than 4 MB ファイルが 4 MB を超えています +@msg the file isn't valid SVG: {e} ファイルが有効な SVG ではありません: {e} +@msg the file names no plate ファイルに版が指定されていません +@msg the function's prototype is not an object 関数のプロトタイプがオブジェクトではありません +@msg the guide is hidden or locked ガイドが非表示またはロックされています +@msg the library's file is outside the read roots ライブラリのファイルが読み取りルートの外にあります +@msg the line is too long, or the paths it passes too many, to check 線が長すぎるか、通過するパスが多すぎるため、確認できません +@msg the method needs an XML value このメソッドには XML の値が必要です +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) ペイントがフリーフォームグラデーションではありません (paint.editGradient {kind} で適用してください) +@msg the parameters or body do not form one function パラメーターまたは本体が 1 つの関数になっていません +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it ペーストした SVG フィルター {name} は、ドキュメントにある同名のフィルターと描画結果が異なり、使用できる空き名もありません +@msg the paths are too many or too tangled to find where they cross パスが多すぎるか複雑に絡み合っているため、交差する位置を見つけられません +@msg the pen table holds more than 255 pens ペンテーブルに 255 本を超えるペンがあります +@msg the plates can't be kept 版を保持できません +@msg the position table does not match the instructions 位置テーブルが命令と一致しません +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes prompt のテキストが {MAX_PROMPT_BYTES} バイトを超えています +@msg the radix must be between 2 and 36 基数には 2 から 36 までの値を指定してください +@msg the rectangle or line reaches too many paths to check 長方形または線にかかるパスが多すぎるため、確認できません +@msg the right side of 'in' is not an object 'in' の右辺がオブジェクトではありません +@msg the right side of 'instanceof' is not a function 'instanceof' の右辺が関数ではありません +@msg the selected art is nested more than {MAX_DEPTH} levels deep 選択したアートの入れ子が {MAX_DEPTH} 階層を超えています +@msg the selected art is too large to measure 選択したアートが大きすぎて計測できません +@msg the selection has no outlines to weed around 選択範囲に、カス取りの対象となるアウトラインがありません +@msg the value bind was called on is not a function bind の呼び出し対象の値が関数ではありません +@msg the value is not a constructor 値がコンストラクターではありません +@msg the value is not a function 値が関数ではありません +@msg the value is not a valid XML name 値が有効な XML 名ではありません +@msg the value is not an XML attribute name 値が XML 属性名ではありません +@msg the value is not an XML name 値が XML 名ではありません +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines カス取りラインがアートのアウトラインと交差する回数が多すぎて計測できません。ラインの数を減らしてください +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap カス取りラインに近すぎるアウトラインのセグメントが多すぎて計測できません。ラインの数を減らすか、間隔を小さくしてください +@msg the weeding lines would be more than {max_pieces} pieces カス取りラインが {max_pieces} 個を超えます +@msg the {_1} plate lies outside the file {_1} 版がファイルの外にあります +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that これはバイナリ DXF ファイルのため読み込めません: CAD アプリで ASCII DXF として保存し、そのファイルを開いてください +@msg this warp's points can't be edited: use Reset with Mesh first このワープのポイントは編集できません: 先に「メッシュでリセット」を使用してください +@msg toExponential() digits must be between 0 and 20 toExponential() の桁数には 0 から 20 までの値を指定してください +@msg toFixed() digits must be between 0 and 20 toFixed() の桁数には 0 から 20 までの値を指定してください +@msg toISOString is not a function toISOString は関数ではありません +@msg toPrecision() argument must be between 1 and 21 toPrecision() の引数には 1 から 21 までの値を指定してください +@msg toSource: a getter cannot be written as source toSource: ゲッターはソースとして書き出せません +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: 要素以外のプロパティ、列挙可能でないプロパティ、または別のプロトタイプを持つ配列は、ソースとして書き出せません +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: 列挙可能でないプロパティを持つオブジェクトや、プロトタイプが Object.prototype ではないオブジェクトは、ソースとして書き出せません +@msg toSource: the source is too long toSource: ソースが長すぎます +@msg toSource: the value contains itself toSource: 値が自身を含んでいます +@msg toSource: the value has too many entries toSource: 値のエントリが多すぎます +@msg toSource: the value is nested too deep toSource: 値の入れ子が深すぎます +@msg toString is not a function toString は関数ではありません +@msg too many arguments 引数が多すぎます +@msg too many quantifiers 量指定子が多すぎます +@msg two composite fonts are named `{_1}` `{_1}` という名前の合成フォントが 2 つあります +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) `{t}` は不明な OpenType 機能です (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt。オフにするには先頭に - を付けます。swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt には、`salt=2` のように `tag=N` の形式で代替字形の番号を指定できます。N は 0 から {_1} まで) +@msg unknown dither `{d}` (diffusion, pattern, noise or none) `{d}` は不明なディザの指定です (diffusion、pattern、noise、none のいずれか) +@msg unknown key `{k}`; a font takes name and parts `{k}` は不明なキーです。フォントに指定できるのは name と parts です +@msg unknown panel `{panel}` `{panel}` は不明なパネルです +@msg unknown parameter `{key}` `{key}` は不明なパラメーターです +@msg unknown parameter `{within}settings.{format}`; {why} `{within}settings.{format}` は不明なパラメーターです。{why} +@msg unknown parameter `{within}{key}`; {why} `{within}{key}` は不明なパラメーターです。{why} +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) `{reduction}` は不明な減色の指定です (selective、perceptual、adaptive、web、gray、blackWhite、uniform、document のいずれか) +@msg unmatched ')' 対応していない ')' があります +@msg unterminated character class 文字クラスが閉じられていません +@msg unterminated group グループが閉じられていません +@msg wordGap and lineGap need area type in the selection wordGap と lineGap を使用するには、選択範囲にエリア内文字が必要です +@msg xpath needs its variables as an object xpath の変数にはオブジェクトを指定してください +@msg xpath needs the expression as a string xpath の式には文字列を指定してください +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}。また、設定とキャッシュを {_1} から {_2} に戻せません: {e} +@msg {_1} is not a folder: {e} {_1} はフォルダーではありません: {e} +@msg {_1}: {_2} and {_3} are in the way {_1}: {_2} と {_3} が妨げになっています +@msg {code}: {0} {code}: {0} +@msg {dir}: {e} {dir}: {e} +@msg {file} names more than one linked JavaScript file {file} にリンクされた JavaScript ファイルが複数指定されています +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: カスタム矢印のパスには塗りつぶせる領域が必要で、アンカーは最大 {most} 個、サブパスは最大 {most} 個、各アンカーは先端から矢印単位で {far} 以内に収めてください +@msg {k}: bad custom arrowhead path: {e} {k}: カスタム矢印のパスが正しくありません: {e} +@msg {k}: bad custom arrowhead: {e} {k}: カスタム矢印が正しくありません: {e} +@msg {k}: baseline must be {_1} to {_2}% {k}: baseline には {_1}~{_2}% を指定してください +@msg {k}: custom must be a name {k}: custom には名前を指定してください +@msg {k}: give a font {k}: フォントを指定してください +@msg {k}: only kana can be scaled at the center {k}: 中央を基準に拡大・縮小できるのはかなだけです +@msg {k}: only kana, roman and numerals can be scaled {k}: 拡大・縮小できるのはかな、欧文、数字だけです +@msg {k}: scale must be {_1} to {_2}% {k}: scale には {_1}~{_2}% を指定してください +@msg {k}: size must be {_1} to {_2}% {k}: size には {_1}~{_2}% を指定してください +@msg {name} must be a number {name} には数値を指定してください +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy で DCS ファイルを使用できるのは、版がファイル内にある場合だけです。版ファイルのある場所でそのファイルに再リンクしてください +@msg {path}: larger than {_1} MB {path}: {_1} MB を超えています +@msg {shown} is not an XPath 1.0 function {shown} は XPath 1.0 の関数ではありません +@msg {v} names no layer or sublayer {v} に該当するレイヤーまたはサブレイヤーはありません +@msg {what} is not a function {what} は関数ではありません +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what} では 1 枚の用紙に {MAX_COPIES_PER_SHEET} 部を超えてプリントされます: 拡大してください +@msg {what} {i} out of range {what} {i} が範囲外です +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. 1 つのプロファイルに適用できる回転体効果は 1 つです。既存の効果のオプションを編集してください。 +@msg Add Points at Crossings: a path went missing 交差点にポイントを追加: パスが見つからなくなりました +@msg CSS options: {e} CSS オプション: {e} +@msg CSV has a header but no data rows CSV にヘッダーはありますが、データ行がありません +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV に NUL バイトが含まれています: UTF-8、またはバイトオーダーマーク付きの UTF-16 で保存してください +@msg CSV is empty CSV が空です +@msg CSV is larger than {MAX_CSV_BYTES} bytes CSV が {MAX_CSV_BYTES} バイトを超えています +@msg CSV is not UTF-16 CSV が UTF-16 ではありません +@msg CSV is not UTF-8 CSV が UTF-8 ではありません +@msg CSV would be larger than {MAX_CSV_BYTES} bytes CSV が {MAX_CSV_BYTES} バイトを超えます +@msg Choose original artwork, not a dimension's generated paths 寸法から生成されたパスではなく、元のアートワークを選択してください +@msg Contour: select paths or shapes 輪郭: パスまたはシェイプを選択してください +@msg Contour: the Offset Path effect is not available 輪郭: 「パスのオフセット」効果を使用できません +@msg Create needs fills, strokes or both 「作成」には塗り、線、またはその両方が必要です +@msg DXF options: {e} DXF オプション: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} DXF の拡大・縮小率には {_1} 以下の正の数値を指定してください ({scale} は指定できません) +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Dimension is locked or hidden 寸法がロックされているか非表示です +@msg Dimension mode does not match its source 寸法のモードが元のオブジェクトと一致しません +@msg Draw Inside needs exactly one selected path, compound path or text 「内側描画」を使用するには、パス、複合パス、テキストのいずれかを 1 つだけ選択してください +@msg EPS Save writes all artwork: use document.export for selected-only EPS EPS の保存ではすべてのアートワークを書き出します: 選択部分だけの EPS には document.export を使用してください +@msg EPS flattener: {e} EPS の分割・統合: {e} +@msg EPS options: {e} EPS オプション: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft 「スクリーン用に書き出し」で書き出せるのは png、png8、jpg、webp、gif、tiff、bmp、tga、psd、svg、svgz、pdf、eps、ai、vectorcraft です +@msg Flare click coordinates must be finite numbers フレアのクリック座標には有限の数値を指定してください +@msg HTML Text applies to object slices of type only 「HTML テキスト」を適用できるのは文字のオブジェクトスライスだけです +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) 「オブジェクトの識別方法」が XML ID になっています: 名前には有効な XML ID を指定してください (先頭は文字、アンダースコア、コロンのいずれか。スペースは使用できません) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) 画像トレースに失敗しました: 内部エラー: スレッドが停止しました (このバグを報告してください) +@msg Image Trace failed: {e} 画像トレースに失敗しました: {e} +@msg Image Trace finished, but its document was closed 画像トレースは完了しましたが、ドキュメントが閉じられています +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again 画像トレースは完了しましたが、その間に画像または設定が変更されました: もう一度トレースしてください +@msg Invalid dimension offset 寸法のオフセットが無効です +@msg Invalid point 点が無効です +@msg Join: a subpath went missing 連結: サブパスが見つからなくなりました +@msg Lock Workspace is not available in VectorCraft 「ワークスペースをロック」は VectorCraft では使用できません +@msg Move Panel to New Window is not available: floating panels stay in the main window 「パネルを新規ウィンドウに移動」は使用できません: フローティングパネルはメインウィンドウ内に表示されます +@msg Offset Path: the path is too complex to offset パスのオフセット: パスが複雑すぎてオフセットできません +@msg Offset Path: {e} パスのオフセット: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported PDF の内容が不完全か、読み込み機能の上限を超えています。ドキュメントは読み込まれませんでした +@msg PDF flattener: {e} PDF の分割・統合: {e} +@msg PDF options: {e} PDF オプション: {e} +@msg Recolor Artwork is no longer open 「アートワークを再配色」はすでに閉じられています +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG が UTF-8、UTF-16、ISO 8859-1 のいずれでもありません +@msg Save writes one artboard: name one, or export several with document.export 保存で書き出せるアートボードは 1 つです: 1 つ指定するか、複数の場合は document.export で書き出してください +@msg Select a dimension first 先に寸法を選択してください +@msg Select dimensions to update 更新する寸法を選択してください +@msg Select only dimensions 寸法だけを選択してください +@msg Select two or more objects, or a group 2 つ以上のオブジェクト、またはグループを選択してください +@msg Shaper needs 1–64 source paths Shaper には 1~64 個の元パスが必要です +@msg Switch x/y is for scatter graphs 「X/Y を入れ替え」は散布図用です +@msg Text options: {e} テキストオプション: {e} +@msg `align` is start, center or end `align` には start、center、end のいずれかを指定してください +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` には [subpath, anchor] の組の配列を指定してください +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` にはアンカーのインデックス (0, 1, 2…) を指定してください ({k} は指定できません) +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` にはアンカーのインデックス (0, 1, 2…) を指定してください ({v} は指定できません) +@msg `artboards` must be a list of artboard indices `artboards` にはアートボードのインデックスのリストを指定してください +@msg `asset` is the asset id (see assets.list) `asset` にはアセット ID を指定してください (assets.list を参照) +@msg `at` must be a byte offset `at` にはバイトオフセットを指定してください +@msg `autoLeading` must be a number `autoLeading` には数値を指定してください +@msg `background` must be "", "matte" or #rrggbb `background` には ""、"matte"、#rrggbb のいずれかを指定してください +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` には "none"、"standard"、"forced" のいずれかを指定してください +@msg `center` must be [x, y] `center` には [x, y] を指定してください +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` には "romanBaseline"、"emBoxTop"、"emBoxCenter"、"emBoxBottom"、"icfTop"、"icfBottom" のいずれかを指定してください +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` には "default"、"leftToRight"、"rightToLeft" のいずれかを指定してください +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke` には `stroke: false` と `ids: []` が必要で、`stop` は指定できません +@msg `clear` must be a list of type object ids, not {v} `clear` には文字オブジェクトの ID のリストを指定してください ({v} は指定できません) +@msg `clear` must list type objects; {_1} is not one `clear` には文字オブジェクトを指定してください。{_1} は文字オブジェクトではありません +@msg `clear` must not list `id` `clear` に `id` を含めることはできません +@msg `color` needs a `rule` to make the theme from `color` には、テーマの作成に使用する `rule` が必要です +@msg `colors` is empty `colors` が空です +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` には vertical、uniform、repeating、sliding のいずれかを指定してください +@msg `columns` must be a whole number 1..50 `columns` には 1..50 の整数を指定してください +@msg `commands` must not be empty `commands` を空にすることはできません +@msg `contents` goes with `to` `contents` は `to` と一緒に指定してください +@msg `corners` must be sharp, round or square, not `{other}` `corners` には sharp、round、square のいずれかを指定してください (`{other}` は指定できません) +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` にはアンカーのインデックス (0, 1, 2…) を指定してください ({v} は指定できません) +@msg `count` must be a whole number from 1 to 100 `count` には 1 から 100 までの整数を指定してください +@msg `dataBase64` is larger than the module limit `dataBase64` がモジュールの上限を超えています +@msg `dataBase64` is not valid base64 `dataBase64` が有効な base64 ではありません +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` は `ids`、`reverse`、`append` と同時に使用できません +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` と `childLimit` はサマリーを絞り込みます: `summary: true` を指定してください +@msg `design` is a design name, or null for plain columns `design` にはデザイン名を指定します。通常の棒にするには null を指定します +@msg `design` is a design name, or null for the default marker `design` にはデザイン名を指定します。初期設定のマーカーにするには null を指定します +@msg `destinationFolder` is required when destination is `folder` destination が `folder` の場合は `destinationFolder` が必要です +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` には `none`、`saveAndClose`、`folder` のいずれかを指定してください +@msg `diameter` must be from 0.1 to 2500 pt `diameter` には 0.1 から 2500 pt までの値を指定してください +@msg `digits` must be one of {_1} `digits` には {_1} のいずれかを指定してください +@msg `dimImages` is a percentage or false `dimImages` にはパーセント値または false を指定してください +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` には "auto"、"leftToRight"、"rightToLeft" のいずれかを指定してください +@msg `each` is the value one design stands for, more than 0 `each` にはデザイン 1 つが表す値を 0 より大きい数で指定してください +@msg `end` must be "start" or "end" `end` には "start" または "end" を指定してください +@msg `ends` applies to joining exactly two paths `ends` を使用できるのは、ちょうど 2 つのパスを連結する場合だけです +@msg `event` must be one of {_1} `event` には {_1} のいずれかを指定してください +@msg `every` must be "all", "odd" or "even", not {v} `every` には "all"、"odd"、"even" のいずれかを指定してください ({v} は指定できません) +@msg `expand` must be true or false `expand` には true または false を指定してください +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` には、直前の text.findFontFiles の検索で見つかったフォントファイルのパスを 1 ~ 1000 個指定してください +@msg `folder` must be an absolute folder path `folder` にはフォルダーの絶対パスを指定してください +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` には、{"wght"} のような軸タグと値のオブジェクトを指定してください +@msg `format` must be a string `format` には文字列を指定してください +@msg `format` must be one of {_1} `format` には {_1} のいずれかを指定してください +@msg `fractions` is chop or scale `fractions` には chop または scale を指定してください +@msg `from` and `to` must differ `from` と `to` には異なる値を指定してください +@msg `from` has an empty frame `from` に空のフレームがあります +@msg `from` must be a non-negative integer `from` には 0 以上の整数を指定してください +@msg `from` must be area type or type on a path `from` にはエリア内文字またはパス上文字を指定してください +@msg `gid` must be a glyph id, not {v} `gid` にはグリフ ID を指定してください ({v} は指定できません) +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` は 1 つの文字に設定するもので、文字スタイルには設定できません (text.setGlyphAlternate で設定します) +@msg `guide` must be a guide index (guide.list) `guide` にはガイドのインデックスを指定してください (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` には default、left、center、right のいずれかを指定してください +@msg `id` must be a node id `id` にはノード ID を指定してください +@msg `id` must be an object id, not {v} `id` にはオブジェクト ID を指定してください ({v} は指定できません) +@msg `ids` must name Objects on Path groups or objects in them `ids` には「パス上のオブジェクト」グループ、またはその中のオブジェクトを指定してください +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` に指定できる ID は最大 {MAX_INSPECT_IDS} 個です +@msg `imageMap` must be none, rectangle or polygon `imageMap` には none、rectangle、polygon のいずれかを指定してください +@msg `includeArtboardFill` must be true or false `includeArtboardFill` には true または false を指定してください +@msg `includeSubdirs` must be true or false `includeSubdirs` には true または false を指定してください +@msg `index` must be a point index `index` にはポイントのインデックスを指定してください +@msg `index` must be a whole number or null `index` には整数または null を指定してください +@msg `indices` must be a non-empty array of item indices `indices` には項目のインデックスを 1 つ以上含む配列を指定してください +@msg `items` is a list of {kind, item} `items` には {kind, item} のリストを指定してください +@msg `items` is empty `items` が空です +@msg `items` must be a list of {id, points} `items` には {id, points} のリストを指定してください +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` には {{id, anchors?}} のリストを指定してください ({v} は指定できません) +@msg `items` must be an array of {id, anchors} `items` には {id, anchors} の配列を指定してください +@msg `items` must be an array of {{id, corners?}}, not {v} `items` には {{id, corners?}} の配列を指定してください ({v} は指定できません) +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` には "none"、"short"、"medium"、"long"、"stylistic" のいずれかを指定してください +@msg `kind` is one of {_1} `kind` には {_1} のいずれかを指定してください +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` には "round"、"invertedRound"、"chamfer" のいずれかを指定してください ({v} は指定できません) +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` には `text`、`visibility`、`image`、`graph` のいずれかを指定してください +@msg `kind` must be a string `kind` には文字列を指定してください +@msg `kind` must be image, noImage or htmlText `kind` には image、noImage、htmlText のいずれかを指定してください +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` には "none"、"hard"、"soft" のいずれかを指定してください +@msg `label` must be a non-empty name of at most 200 characters `label` には 200 文字以内の空でない名前を指定してください +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` には、"Turkish" や "tr" のような言語名またはタグを指定してください +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` には "romanBaseline" または "emBoxTop" を指定してください +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` には 0.1 から 5000 までの値を指定してください。自動にするには null を指定します +@msg `length` must be a number of at most 1000000 pt either way `length` には正負いずれの方向でも 1000000 pt 以内の数値を指定してください +@msg `line` must be true or false `line` には true または false を指定してください +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) を `maxAngle` ({max_angle}°) より大きくすることはできません +@msg `mode` must be "handles", "straight" or "smooth" `mode` には "handles"、"straight"、"smooth" のいずれかを指定してください +@msg `mode` must be "straight", "bezier" or "arc" `mode` には "straight"、"bezier"、"arc" のいずれかを指定してください +@msg `mode` must be one of {_1} `mode` には {_1} のいずれかを指定してください +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` には "none"、"yakumonoHankaku"、"gyomatsuYakumonoHankaku"、"gyomatsuYakumonoZenkaku"、"yakumonoZenkaku"、"lineEndHalf" のいずれかを指定してください +@msg `names` is a list of preset names `names` にはプリセット名のリストを指定してください +@msg `names` lists preset names `names` にはプリセット名を指定してください +@msg `names` must be a list of names `names` には名前のリストを指定してください +@msg `names` must be a list of swatch names `names` にはスウォッチ名のリストを指定してください +@msg `names` must be an array of style names `names` にはスタイル名の配列を指定してください +@msg `new` is a design name, or true `new` にはデザイン名または true を指定してください +@msg `path` must be a folder `path` にはフォルダーを指定してください +@msg `path` must be a path object `path` にはパスオブジェクトを指定してください +@msg `pivot` must be a whole number from 0 to 8 `pivot` には 0 から 8 までの整数を指定してください +@msg `points` must be a list of [x, y] points `points` には [x, y] の点のリストを指定してください +@msg `points` must be a list of point indexes `points` にはポイントのインデックスのリストを指定してください +@msg `points` must be an array of point indices `points` にはポイントのインデックスの配列を指定してください +@msg `points` must be point indexes `points` にはポイントのインデックスを指定してください +@msg `port` must be "in" or "out" `port` には "in" または "out" を指定してください +@msg `position` is above or below `position` には above または below を指定してください +@msg `preset` must be a string `preset` には文字列を指定してください +@msg `ratio` must be from 0.1 to 0.5 `ratio` には 0.1 から 0.5 までの値を指定してください +@msg `rect` must be [x, y, width, height] with a positive size `rect` には、サイズが正の [x, y, width, height] を指定してください +@msg `relation` must be overlap, inside or outside `relation` には overlap、inside、outside のいずれかを指定してください +@msg `reversed` must be true or false `reversed` には true または false を指定してください +@msg `rotate` must be a finite number `rotate` には有限の数値を指定してください +@msg `rule` must be nonZero or evenOdd `rule` には nonZero または evenOdd を指定してください +@msg `scale` must be a number in (0, 100] `scale` には (0, 100] の範囲の数値を指定してください +@msg `scope` must be "selection" or "document", not {other} `scope` には "selection" または "document" を指定してください ({other} は指定できません) +@msg `script` must be a string `script` には文字列を指定してください +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` には系列のインデックスを 1 つ以上含むリストを指定してください +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` には `type` または `valueAxis` が必要です +@msg `shift` must be a number of points `shift` にはポイント単位の数値を指定してください +@msg `showCenter` must be true or false `showCenter` には true または false を指定してください +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` には "fullJustify"、"alignLeft"、"alignCenter"、"alignRight" のいずれかを指定してください +@msg `size` is a percentage of the base text's size, 1 to 100 `size` には、親文字のサイズに対する割合を 1 から 100 までのパーセント値で指定してください +@msg `source` and `target` are the same object `source` と `target` が同じオブジェクトです +@msg `source` must be `folder` or `datasets` `source` には `folder` または `datasets` を指定してください +@msg `spacing` must be a number `spacing` には数値を指定してください +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` には 0 以上のポイント単位の数値、または "auto" を指定してください +@msg `start` must be a finite number `start` には有限の数値を指定してください +@msg `starts` must be an array of anchor indices (or nulls) `starts` にはアンカーのインデックス (または null) の配列を指定してください +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` には linked、embedded、missing、modified のいずれか、またはそのリストを指定してください +@msg `stop` must be a stop index `stop` には分岐点のインデックスを指定してください +@msg `story` styles the whole story: give no `start` or `end` `story` はストーリー全体にスタイルを設定します: `start` と `end` は指定しないでください +@msg `story` takes the whole story: give no `start` or `end` `story` はストーリー全体を対象にします: `start` と `end` は指定しないでください +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` は線に適用されます (stroke: true) +@msg `style` must be roman or japanese `style` には roman または japanese を指定してください +@msg `subpaths` must be two integer indices `subpaths` には整数のインデックスを 2 つ指定してください +@msg `symbol` must be a symbol name `symbol` にはシンボル名を指定してください +@msg `target` must be "object" or "contents", not {v} `target` には "object" または "contents" を指定してください ({v} は指定できません) +@msg `tint` applies to global and spot colours `tint` はグローバルカラーと特色に適用されます +@msg `tint` goes with a global or spot `swatch` `tint` はグローバルまたは特色の `swatch` と一緒に指定してください +@msg `tint` must be a number (%) `tint` には数値 (%) を指定してください +@msg `tint` needs a global or spot colour `tint` にはグローバルカラーまたは特色が必要です +@msg `to` is already threaded `to` はすでにスレッドされています +@msg `to` is the design's new name `to` にはデザインの新しい名前を指定してください +@msg `to` must be [x, y] `to` には [x, y] を指定してください +@msg `to` must be a closed path, area type or type on a path other than `from` `to` には、`from` 以外のクローズパス、エリア内文字、パス上文字のいずれかを指定してください +@msg `to` must be a whole number `to` には整数を指定してください +@msg `to` takes the rows of one object's stack, not aligned rows `to` には、整列した行ではなく、1 つのオブジェクトのスタック内の行を指定してください +@msg `useLab` must be true or false `useLab` には true または false を指定してください +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` には default、top、middle、baseline、bottom のいずれかを指定してください +@msg `values` must be a list `values` にはリストを指定してください +@msg `values` must be an object `values` にはオブジェクトを指定してください +@msg `values` must name hex, rgb, cmyk, lab or gray `values` には hex、rgb、cmyk、lab、gray のいずれかを指定してください +@msg `values` names a kind twice `values` に同じ種類が 2 回指定されています +@msg `{_1}` can't be edited or removed `{_1}` は編集も削除もできません +@msg `{_1}` is built in and always listed `{_1}` は組み込みで、常に一覧に表示されます +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` は、直前の text.findFontFiles の検索で見つかったフォントファイルではありません +@msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` がパッケージで上書きされます: 別のフォルダーまたは名前を選択してください +@msg `{file}` has no art to place `{file}` に配置できるアートがありません +@msg `{file}` is over {_1} MB `{file}` が {_1} MB を超えています +@msg `{f}` belongs to a VectorCraft that is running `{f}` は実行中の VectorCraft が使用しています +@msg `{f}` is the copy of a document open now `{f}` は現在開いているドキュメントのコピーです +@msg `{id}` is no writable format (see document.formats) `{id}` は書き出せる形式ではありません (document.formats を参照) +@msg `{id}` isn't a loaded library `{id}` は読み込まれているライブラリではありません +@msg `{key}` has no colours `{key}` にカラーがありません +@msg `{key}` is a design name `{key}` にはデザイン名を指定してください +@msg `{key}` is a design name or a list of them `{key}` にはデザイン名またはそのリストを指定してください +@msg `{key}` is longer than {MAX_NAME} characters `{key}` が {MAX_NAME} 文字を超えています +@msg `{key}` is out of range `{key}` が範囲外です +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` には [min, desired, max] または {{min?, desired?, max?}} をパーセントで指定してください +@msg `{key}` must be a non-negative integer `{key}` には 0 以上の整数を指定してください +@msg `{key}` must be a number {_1}..{_2} `{key}` には {_1}..{_2} の数値を指定してください +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` には 0 以上の整数を指定してください ({n} は指定できません) +@msg `{key}` must be an appearance item index (paint order) or null `{key}` にはアピアランス項目のインデックス (ペイント順) または null を指定してください +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` にはアセット ID の配列を指定してください (assets.list を参照) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` には 0 から 180 度までの値を指定してください ({a} は指定できません) +@msg `{key}` must be from {_1} to {shift_max} `{key}` には {_1} から {shift_max} までの値を指定してください +@msg `{key}`: not a colour: {v} `{key}`: カラーではありません: {v} +@msg `{k}.family` is required `{k}.family` は必須です +@msg `{k}` is a number 0..1 `{k}` には 0..1 の数値を指定してください +@msg `{k}` is true or false `{k}` には true または false を指定してください +@msg `{k}` isn't an export setting: {_1} `{k}` は書き出し設定ではありません: {_1} +@msg `{k}` must be a byte offset `{k}` にはバイトオフセットを指定してください +@msg `{k}` must be a number `{k}` には数値を指定してください +@msg `{k}` must be a number of points `{k}` にはポイント単位の数値を指定してください +@msg `{k}` must be a string `{k}` には文字列を指定してください +@msg `{k}` must be a whole number `{k}` には整数を指定してください +@msg `{k}` must be from {lo} to {hi} for {key} {key} の `{k}` には {lo} から {hi} までの値を指定してください +@msg `{k}` must be {what}, not {v} `{k}` には {what} を指定してください ({v} は指定できません) +@msg `{l}` isn't a global or spot colour `{l}` はグローバルカラーでも特色でもありません +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` はビットマップではありません: clipboard.importSvg、importPdf、importText のいずれかを使用してください +@msg `{name}` can't be deleted `{name}` は削除できません +@msg `{name}` can't be duplicated `{name}` は複製できません +@msg `{name}` can't be edited `{name}` は編集できません +@msg `{name}` can't be edited this way `{name}` はこの方法では編集できません +@msg `{name}` can't be renamed `{name}` は名前を変更できません +@msg `{name}` can't name a folder `{name}` にフォルダーは指定できません +@msg `{name}` has no art to place `{name}` に配置できるアートがありません +@msg `{name}` has no text to place `{name}` に配置できるテキストがありません +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text Word 文書内の `{name}` が UTF-8 または UTF-16 のテキストではありません +@msg `{name}` in the Word document is not well-formed XML: {e} Word 文書内の `{name}` が整形式の XML ではありません: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep Word 文書内の `{name}` で要素の入れ子が {MAX_NESTING} 階層を超えています +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` は組み込みのプリセットで、読み取り専用です: 別の名前で保存してください +@msg `{name}` is a built-in preset: save under another name `{name}` は組み込みのプリセットです: 別の名前で保存してください +@msg `{name}` is a built-in profile and stays `{name}` は組み込みのプロファイルのため、削除されません +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` は `{base}` の濃度違いです: `{base}` を編集してください (濃度スウォッチで変更できるのは名前だけです) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` は作業用スペースです: 先に edit.colorSettings で別のものを選択してください +@msg `{name}` is built in and stays `{name}` は組み込みのため、削除されません +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` には同じ種類のペイント (カラー、グラデーション、パターン) を指定してください +@msg `{name}`: Minimum must not exceed Desired, nor Desired Maximum `{name}`: 最小は最適を、最適は最大を超えないようにしてください +@msg `{n}` can't be deleted `{n}` は削除できません +@msg `{n}` can't be merged `{n}` は結合できません +@msg `{n}` can't be moved `{n}` は移動できません +@msg `{n}` is not a colour swatch `{n}` はカラースウォッチではありません +@msg `{n}` isn't a solid colour `{n}` は単色ではありません +@msg `{n}`: colour groups hold solid colours only `{n}`: カラーグループに入れられるのは単色だけです +@msg `{other}` doesn't trace an image `{other}` では画像をトレースできません +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: このステップの参照でコピーされる値が {_1} MB を超えています +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` は軸タグではありません: タグは "wght" のような 4 文字です +@msg `{to}` is empty or already used `{to}` が空か、すでに使用されています +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` が {MAX_TEXT} バイトを超えています +@msg `{what}` must be text `{what}` にはテキストを指定してください +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard DXF 図面の原点は 1 つです: アートボードごとに 1 つの図面にするには useArtboards: true を指定してください +@msg a PDF presets file with over {_1} presets PDF プリセットファイルに {_1} 個を超えるプリセットがあります +@msg a batch preview cannot switch documents バッチのプレビューではドキュメントを切り替えられません +@msg a brush named `{nn}` exists `{nn}` という名前のブラシがすでにあります +@msg a container of the listed objects is gone 指定したオブジェクトを含むコンテナがなくなりました +@msg a content stream can't be read コンテンツストリームを読み込めません +@msg a content stream isn't a stream コンテンツストリームがストリームではありません +@msg a design holds at most {_1} objects デザインに含められるオブジェクトは最大 {_1} 個です +@msg a design needs a name デザインには名前が必要です +@msg a dictionary grew too large 辞書が大きくなりすぎました +@msg a document keeps at most {_1} graph designs ドキュメントに保存できるグラフのデザインは最大 {_1} 個です +@msg a document keeps at most {_1} saved selections ドキュメントに保存できる選択範囲は最大 {_1} 個です +@msg a document keeps at most {_1} views ドキュメントに保存できる表示は最大 {_1} 個です +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files ドキュメントにリンクできる JavaScript ファイルは最大 {MAX_SVG_SCRIPTS} 個です +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes feConvolveMatrix フィルターの order が {x}×{y} で、usvg が行うカーネルサイズの乗算がオーバーフローします +@msg a gradient preset is already called {n} {n} という名前のグラデーションプリセットがすでにあります +@msg a graph has at most {_1} series, so it transposes with at most that many categories グラフの系列は最大 {_1} 個のため、行と列を入れ替えられるのは項目がその数以下の場合です +@msg a layer can't be merged into its own sublayer レイヤーを自身のサブレイヤーに結合することはできません +@msg a layer's form box isn't an array レイヤーのフォームのボックスが配列ではありません +@msg a layer's form can't be read レイヤーのフォームを読み込めません +@msg a layer's form has no box レイヤーのフォームにボックスがありません +@msg a layer's form has no dictionary レイヤーのフォームに辞書がありません +@msg a legend draws at most {MAX_CHIPS} swatches 凡例に描画できるスウォッチは最大 {MAX_CHIPS} 個です +@msg a name names one asset: give multiple: false, or one object 名前で指定できるアセットは 1 つです: multiple: false を指定するか、オブジェクトを 1 つにしてください +@msg a page can't be read ページを読み込めません +@msg a page object is used by something else ページオブジェクトが別のものに使用されています +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) ページのサムネールには、1 辺が 1 から {THUMBNAIL_SIZE} までの RGB ピクセルを指定してください (指定値: {w} × {h}) +@msg a pattern layer doesn't end what it starts パターンレイヤーで、開始したものが終了していません +@msg a pattern layer's colors hold something else パターンレイヤーのカラーに別のものが含まれています +@msg a pattern named `{n}` exists `{n}` という名前のパターンがすでにあります +@msg a plug-in with id {_1} is already installed ID が {_1} のプラグインはすでにインストールされています +@msg a preset named `{n}` exists `{n}` という名前のプリセットがすでにあります +@msg a print presets file with over {_1} presets プリントプリセットファイルに {_1} 個を超えるプリセットがあります +@msg a profile is already called {n} {n} という名前のプロファイルがすでにあります +@msg a row can't go into itself 行を自身の中に入れることはできません +@msg a series goes on the left or the right value axis 系列は左または右の数値軸に割り当てます +@msg a start must be an anchor index ≥ 0 or null 開始位置には 0 以上のアンカーのインデックスまたは null を指定してください +@msg a style named `{name}` already exists `{name}` という名前のスタイルがすでにあります +@msg a style named `{to}` already exists `{to}` という名前のスタイルがすでにあります +@msg a swatch needs a colour, gradient or pattern スウォッチにはカラー、グラデーション、パターンのいずれかが必要です +@msg a symbol named `{n}` already exists `{n}` という名前のシンボルがすでにあります +@msg a theme has 1 to {THEME_SIZE} colours テーマのカラーは 1 から {THEME_SIZE} 色までです +@msg a theme is already called {name} (replace: "{name}" overwrites it) {name} という名前のテーマがすでにあります (replace: "{name}" で上書きできます) +@msg a toolbar name has at most {MAX_NAME} characters ツールバー名は {MAX_NAME} 文字以内です +@msg a view named `{_1}` exists `{_1}` という名前の表示がすでにあります +@msg a view named `{name}` exists `{name}` という名前の表示がすでにあります +@msg a workspace keeps at most {MAX} toolbars ワークスペースに保存できるツールバーは最大 {MAX} 個です +@msg a {_1} series can't go in a {_2} graph {_1} の系列は {_2} グラフに入れられません +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF 互換、初期設定) または "7" (旧形式の .ai、バージョン 7) を指定してください +@msg already editing an opacity mask すでに不透明マスクを編集中です +@msg already editing another object's opacity mask すでに別のオブジェクトの不透明マスクを編集中です +@msg already in pattern editing mode すでにパターン編集モードです +@msg an artboard is listed twice 同じアートボードが 2 回指定されています +@msg an empty artboard has no width or height to scale to 空のアートボードには、拡大・縮小の基準となる幅も高さもありません +@msg an image is too large for DXF and was left out 画像が DXF には大きすぎるため、除外しました +@msg an image of {_1} MB, at most {_2} MB 画像が {_1} MB あります (上限は {_2} MB) +@msg an image of {declared_w} × {declared_h} pixels is too large to draw {declared_w} × {declared_h} ピクセルの画像は大きすぎて描画できません +@msg an image of {width} × {height} pixels is too large to draw {width} × {height} ピクセルの画像は大きすぎて描画できません +@msg an index must be a whole number インデックスには整数を指定してください +@msg an inline graphic has no glyphs インライングラフィックにグリフがありません +@msg an invisible text form can't be read 非表示テキストのフォームを読み込めません +@msg an object is missing オブジェクトが見つかりません +@msg an object's header is missing オブジェクトのヘッダーがありません +@msg an overprinting form can't be read オーバープリントのフォームを読み込めません +@msg an unread object has no readable bounds 未読み込みのオブジェクトに、読み取れる境界がありません +@msg an unread object holds a frame of threaded type 未読み込みのオブジェクトに、スレッドテキストのフレームが含まれています +@msg an unread object is missing from its layer 未読み込みのオブジェクトがレイヤーにありません +@msg anchor `smooth` must be true or false アンカーの `smooth` には true または false を指定してください +@msg anchor `{key}` must be [x, y] アンカーの `{key}` には [x, y] を指定してください +@msg anchor `{key}` must be a number アンカーの `{key}` には数値を指定してください +@msg angle and roundness must be finite angle と roundness には有限の値を指定してください +@msg angles must be a list as long as pins (degrees or null) angles には pins と同じ長さのリスト (度数または null) を指定してください +@msg appearance must be flat|center|edge appearance には flat|center|edge のいずれかを指定してください +@msg apply is "fill" or "stroke", not `{other}` apply には "fill" または "stroke" を指定してください (`{other}` は指定できません) +@msg area must be a closed polygon of 3 to 100000 [x, y] points area には 3 から 100000 個の [x, y] の点からなる閉じた多角形を指定してください +@msg area type needs a path that encloses an area エリア内文字には、領域を囲むパスが必要です +@msg area type {_1} is named twice エリア内文字 {_1} が 2 回指定されています +@msg arrowAlign must be extend|tip, got {o} arrowAlign には extend|tip のいずれかを指定してください (指定値: {o}) +@msg arrowScale must be [start, end] arrowScale には [start, end] を指定してください +@msg artboard is a 0-based index artboard には 0 から始まるインデックスを指定してください +@msg artboard must be a 0-based index artboard には 0 から始まるインデックスを指定してください +@msg artboard {i} doesn't print アートボード {i} はプリントされません +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout には {layout?, columns?, spacing?, rightToLeft?} を指定してください +@msg artboardLayout.columns must be 1 or more artboardLayout.columns には 1 以上の値を指定してください +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout には gridByRow、gridByColumn、row、column のいずれかを指定してください +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft には true または false を指定してください +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing には 0–{MAX_SPACING} pt を指定してください +@msg artboards must be a non-empty list artboards には空でないリストを指定してください +@msg artboards must be a number artboards には数値を指定してください +@msg asset `{_1}` has no art to export アセット `{_1}` に書き出せるアートがありません +@msg at most {MAX_KEYWORDS} keywords キーワードは最大 {MAX_KEYWORDS} 個です +@msg at most {MAX_MATCHES} matches are kept 保持される一致は最大 {MAX_MATCHES} 件です +@msg at most {MAX_POINTS} points ポイントは最大 {MAX_POINTS} 個です +@msg at most {MAX_QUEUE} files ファイルは最大 {MAX_QUEUE} 個です +@msg at must be [x, y] at には [x, y] を指定してください +@msg at/rect lie outside the canvas at/rect がキャンバスの外にあります +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute には size|leading|tracking|kerning|baselineShift のいずれかを指定してください +@msg attrs must be an object attrs にはオブジェクトを指定してください +@msg automation path rejected: the folder's name isn't valid text 自動処理のパスを拒否しました: フォルダー名が有効なテキストではありません +@msg automation path rejected: {_1} isn't valid text 自動処理のパスを拒否しました: {_1} が有効なテキストではありません +@msg automation path rejected: {why}: {path} 自動処理のパスを拒否しました: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis には "vertical"、"horizontal"、または度数の角度を指定してください (`{given}` は指定できません) +@msg backgroundContents must be transparent or white backgroundContents には transparent または white を指定してください +@msg bad AES key AES キーが正しくありません +@msg bad `runs`: {e} `runs` が正しくありません: {e} +@msg bad arrowScale arrowScale が正しくありません +@msg bad base64 base64 が正しくありません +@msg bad color {c} カラー {c} が正しくありません +@msg bad colour カラーが正しくありません +@msg bad colour in `colors` `colors` 内のカラーが正しくありません +@msg bad colour {v} カラー {v} が正しくありません +@msg bad colour {v} in groupColors groupColors 内のカラー {v} が正しくありません +@msg bad cross-reference entry 相互参照エントリが正しくありません +@msg bad cross-reference table 相互参照テーブルが正しくありません +@msg bad current colour {v} 現在のカラー {v} が正しくありません +@msg bad dataBase64 dataBase64 が正しくありません +@msg bad edit 編集内容が正しくありません +@msg bad fill {v} 塗り {v} が正しくありません +@msg bad fit: {e} fit が正しくありません: {e} +@msg bad model {other} (keep, new or document) model {other} が正しくありません (keep、new、document のいずれか) +@msg bad new colour {v} 新しいカラー {v} が正しくありません +@msg bad object オブジェクトが正しくありません +@msg bad object number オブジェクト番号が正しくありません +@msg bad offset オフセットが正しくありません +@msg bad path data: {e} パスデータが正しくありません: {e} +@msg bad point {v}: expected [x, y] or [x, y, pressure] 点 {v} が正しくありません: [x, y] または [x, y, pressure] を指定してください +@msg bad reference 参照が正しくありません +@msg bad trailer size トレーラーのサイズが正しくありません +@msg bad {k} colour {k} のカラーが正しくありません +@msg batches cannot nest バッチを入れ子にすることはできません +@msg bind something to a variable first 先に何かを変数にバインドしてください +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed には 0–{MAX_BLEED} pt を指定してください: 数値、[top, bottom, left, right]、{{top, bottom, left, right}} のいずれか +@msg brush must be a name or null brush には名前または null を指定してください +@msg by must be a number by には数値を指定してください +@msg can't add page thumbnails: {why} ページのサムネールを追加できません: {why} +@msg can't add the editing data for other apps: {why} 他のアプリ用の編集データを追加できません: {why} +@msg can't edit the written PDF: {why} 書き出した PDF を編集できません: {why} +@msg can't encrypt the PDF: {why} PDF を暗号化できません: {why} +@msg can't linearise the PDF: {why} PDF をリニアライズできません: {why} +@msg can't open `{_1}`: {_2} `{_1}` を開けません: {_2} +@msg can't open `{_1}`: {e} `{_1}` を開けません: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) `{name}` を開けません: VectorCraft で読み込める形式ではありません (document.formats を参照) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) `{name}` を配置できません: VectorCraft で読み込める形式ではありません (document.formats を参照) +@msg can't read back the written PDF: {e} 書き出した PDF を読み戻せません: {e} +@msg can't read this Word document: {why} この Word 文書を読み込めません: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp .{e} 画像は書き出せません: .png、.jpg、.gif、.webp、.tif、.bmp のいずれかを使用してください +@msg can't write PDF layers, overprint or invisible text: {why} PDF レイヤー、オーバープリント、非表示テキストを書き出せません: {why} +@msg can't write the file's copyright: {why} ファイルの著作権情報を書き出せません: {why} +@msg can't write the file's modification date: {why} ファイルの更新日を書き出せません: {why} +@msg can't write the pages' user units: {why} ページのユーザー単位を書き出せません: {why} +@msg cannot distort a zero-size or non-finite bounding box サイズが 0 または有限でないバウンディングボックスは変形できません +@msg cannot move an object into itself オブジェクトを自身の中に移動することはできません +@msg case must be upper|lower|title|sentence case には upper|lower|title|sentence のいずれかを指定してください +@msg category `{c}`: one of {_1} category `{c}`: {_1} のいずれかを指定してください +@msg center must be [x, y] center には [x, y] を指定してください +@msg choose at least one artboard アートボードを 1 つ以上選択してください +@msg choose at least one status 状態を 1 つ以上選択してください +@msg clip must be true or false clip には true または false を指定してください +@msg clippingMask must be true or false clippingMask には true または false を指定してください +@msg closed must be a boolean closed にはブール値を指定してください +@msg collapsed must be true or false collapsed には true または false を指定してください +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: 「{profile}」という名前の RGB または CMYK プロファイルはありません +@msg colorMode must be rgb or cmyk, not `{m}` colorMode には rgb または cmyk を指定してください (`{m}` は指定できません) +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel には {own} (ドキュメントのモード)、grayscale、bitmap のいずれかを指定してください +@msg colour groups can't go into colour groups カラーグループをカラーグループに入れることはできません +@msg colour, mode and spot apply to solid-colour swatches only colour、mode、spot を適用できるのは単色のスウォッチだけです +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read 列 {column}: `{cell}` はグラフデータファイルの `%` 列で、読み込まれません +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable 列 {column}: `{cell}` が指定している `{name}` は {_1} 変数です +@msg column {column}: `{cell}` names no variable 列 {column}: `{cell}` に該当する変数はありません +@msg column {column}: the variable name is longer than {MAX_NAME} characters 列 {column}: 変数名が {MAX_NAME} 文字を超えています +@msg column: true floats the whole column and takes no onto, above or below column: true は列全体をフローティングにするため、onto、above、below は指定できません +@msg command must be a shape.* command command には shape.* コマンドを指定してください +@msg commands[{i}]: missing `command` commands[{i}]: `command` がありません +@msg composer must be "singleLine" or "everyLine" composer には "singleLine" または "everyLine" を指定してください +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes 複合シェイプに含められるのは、パス、複合パス、グループ、複合シェイプ、文字、ブレンド、エンベロープです +@msg compound {_1} must contain exactly one open path to join 連結するには、複合パス {_1} にオープンパスがちょうど 1 つ含まれている必要があります +@msg connect end must be "start" or "end" connect の end には "start" または "end" を指定してください +@msg connect needs a path other than the one extended connect には、延長したパスとは別のパスが必要です +@msg connect needs id connect には id が必要です +@msg connect path has no open end connect のパスに開いた端点がありません +@msg copy needs path: the file to copy into the document's folder copy には path が必要です: ドキュメントのフォルダーにコピーするファイルを指定してください +@msg copy takes images only: relink placed documents where their files are copy を使用できるのは画像だけです: 配置したドキュメントは、ファイルのある場所で再リンクしてください +@msg copying a file isn't available on the web Web 版ではファイルをコピーできません +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus には unknown、copyrighted、publicDomain のいずれかを指定してください +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl には URL を指定してください (スペースなし、2048 文字以内) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners には [x, y] の点を 4 つ指定してください (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item または file を指定してください +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: アート、仕上がり、裁ち落とし、メディアの各ボックスがあるのは PDF ページだけです。"crop" または "bounding" を使用してください +@msg crop rectangle doesn't overlap the image 切り抜きの長方形が画像と重なっていません +@msg cross-reference and object streams aren't supported 相互参照ストリームとオブジェクトストリームには対応していません +@msg damaged compressed data: {e} 圧縮データが破損しています: {e} +@msg dash lengths must be numbers, got {v} ダッシュの長さには数値を指定してください (指定値: {v}) +@msg dash must be a list of lengths or null, got {v} dash には長さのリストまたは null を指定してください (指定値: {v}) +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) データキー `{k}`: 使用できるのは文字、数字、-、_、. です (name と vc-… は使用できません) +@msg data must be an object of key: value, not {v} data には key: value のオブジェクトを指定してください ({v} は指定できません) +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare データセット `{name}` に、ライブラリで宣言されていない `{var}` の値があります +@msg data set `{name}` holds two values for `{var}` データセット `{name}` に `{var}` の値が 2 つあります +@msg data set `{name}` is declared twice データセット `{name}` が 2 回宣言されています +@msg data takes at most {MAX_DATA} keys at once data に一度に指定できるキーは最大 {MAX_DATA} 個です +@msg dataset `{name}` already exists データセット `{name}` はすでにあります +@msg dataset `{to}` already exists データセット `{to}` はすでにあります +@msg date `{d}`: YYYY-MM-DD date `{d}`: YYYY-MM-DD の形式で指定してください +@msg decoded data is too large デコードしたデータが大きすぎます +@msg define a variable first 先に変数を定義してください +@msg detect must be true or false detect には true または false を指定してください +@msg direct-select anchor points of a path パスのアンカーポイントをダイレクト選択してください +@msg distance must be > 0 distance には 0 より大きい値を指定してください +@msg dx and dy give the arrow's direction dx と dy には矢印の方向を指定してください +@msg dx and dy must be numbers dx と dy には数値を指定してください +@msg dxf fitTo must be [width, height], positive dxf の fitTo には正の値の [width, height] を指定してください +@msg dxf must be an object of DXF import options dxf には DXF 読み込みオプションのオブジェクトを指定してください +@msg dxf scale must be a positive number up to {_1}, not {s} dxf の scale には {_1} 以下の正の数値を指定してください ({s} は指定できません) +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf の unit `{u}`: mm、cm、in、pt などの単位を指定してください +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} 各形式にはオブジェクト {format, scale?, suffix?} を指定してください +@msg each item needs an id 各項目には id が必要です +@msg each match needs `kind` 各一致には `kind` が必要です +@msg each match needs a `name` 各一致には `name` が必要です +@msg each of `fonts` is {family, style?}; 1 to 1000 of them `fonts` の各要素には {family, style?} を指定してください。要素数は 1 から 1000 個です +@msg each of `items` is {kind, item} `items` の各要素には {kind, item} を指定してください +@msg each of `items` is {{id, corners?}} with an object id, not {it} `items` の各要素には、オブジェクト ID を含む {{id, corners?}} を指定してください ({it} は指定できません) +@msg each row needs `from` 各行には `from` が必要です +@msg each stop needs a position 各分岐点には位置が必要です +@msg each subpath needs an anchors array 各サブパスには anchors 配列が必要です +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType には easeIn、easeOut、easeInOut、easeOutIn のいずれかを指定してください +@msg editingData must be true or false editingData には true または false を指定してください +@msg editingData must be true, false or "always" editingData には true、false、"always" のいずれかを指定してください +@msg edits overlap 編集範囲が重なっています +@msg effects of objects whose appearances differ can't be moved together アピアランスが異なるオブジェクトの効果は、まとめて移動できません +@msg empty header in column {_1} 列 {_1} のヘッダーが空です +@msg encoding `{_1}`: utf8 or utf16 encoding `{_1}`: utf8 または utf16 を指定してください +@msg ends must be two of "first" and "last" ends には "first" と "last" から 2 つを指定してください +@msg every numbered name for the result is taken 結果に使用できる番号付きの名前がすべて使用されています +@msg expanded SVG styles exceed the import size limit 展開した SVG スタイルが読み込みサイズの上限を超えています +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes 書き出しプリセットファイルが {MAX_PRESET_FILE_BYTES} バイトを超えています +@msg exportText must be editable or appearance exportText には editable または appearance を指定してください +@msg exportType must be movieClip or graphic, not `{s}` exportType には movieClip または graphic を指定してください (`{s}` は指定できません) +@msg extend needs id extend には id が必要です +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges に指定できるタグは最大 {MAX_FEATURE_CHANGES} 個です +@msg featureChanges must be a list of tags featureChanges にはタグのリストを指定してください +@msg features must be a list of tags features にはタグのリストを指定してください +@msg fidelity must be 0..100 fidelity には 0..100 の値を指定してください +@msg files must be {name, dataBase64} files には {name, dataBase64} を指定してください +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit には none|autoHeight|shrinkText|block のいずれかを指定してください (指定値: {id}) +@msg flattener: {e} 分割・統合: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high、medium、low、または保存済みのプリセットを指定してください (flattener.presets.list を参照) +@msg flipProfile must be along|across, got {o} flipProfile には along|across のいずれかを指定してください (指定値: {o}) +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none、rows、format、item、itemFormat のいずれかを指定してください +@msg folders deeper than {MAX_BATCH_DEPTH} levels フォルダーの階層が {MAX_BATCH_DEPTH} を超えています +@msg folders deeper than {MAX_DEPTH} levels フォルダーの階層が {MAX_DEPTH} を超えています +@msg font family `{family}` is not available フォントファミリー `{family}` は使用できません +@msg font family `{tf}` is not available フォントファミリー `{tf}` は使用できません +@msg format `{f}`: pdf or postscript format `{f}`: pdf または postscript を指定してください +@msg format `{f}`: text or json format `{f}`: text または json を指定してください +@msg format is `{format}` format は `{format}` です +@msg formats must be an array of rows, not {v} formats には行の配列を指定してください ({v} は指定できません) +@msg frequency must be between 1 and 300 lines per inch frequency には 1 から 300 線/インチまでの値を指定してください +@msg from and to must be finite from と to には有限の値を指定してください +@msg from must be [x0, y0, x1, y1] from には [x0, y0, x1, y1] を指定してください +@msg give `before` or `after`, not both `before` または `after` のどちらか一方を指定してください +@msg give `colors`, or `color` and `rule` `colors`、または `color` と `rule` を指定してください +@msg give `event`, such as onclick onclick などの `event` を指定してください +@msg give `file`, the JavaScript file's path or URL `file` に JavaScript ファイルのパスまたは URL を指定してください +@msg give `fill` and/or `stroke` `fill` と `stroke` の一方または両方を指定してください +@msg give `fill`, `stroke` or both `fill`、`stroke`、またはその両方を指定してください +@msg give `folder` `folder` を指定してください +@msg give `fonts`, or open a document `fonts` を指定するか、ドキュメントを開いてください +@msg give `from` or `indices`, not both `from` または `indices` のどちらか一方を指定してください +@msg give `id` (no text is being edited) `id` を指定してください (編集中のテキストがありません) +@msg give `id` and `anchors`, or `items`, not both `id` と `anchors`、または `items` のどちらか一方を指定してください +@msg give `id` or `guide`, not both `id` または `guide` のどちらか一方を指定してください +@msg give `length` or `to` `length` または `to` を指定してください +@msg give `matches`: [{kind, name, target}] `matches` を指定してください: [{kind, name, target}] +@msg give `name` (or edit a pattern) `name` を指定してください (またはパターンを編集してください) +@msg give `name` or `names` `name` または `names` を指定してください +@msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (.wasm ファイル) または `dataBase64` (モジュール) を指定してください +@msg give `path`, `data` or `dataBase64` `path`、`data`、`dataBase64` のいずれかを指定してください +@msg give `path`, `data`, `dataBase64` or `csv` `path`、`data`、`dataBase64`、`csv` のいずれかを指定してください +@msg give `path`, `xml` or `dataBase64` `path`、`xml`、`dataBase64` のいずれかを指定してください +@msg give `script`, the JavaScript the event runs ("" removes the event) `script` に、イベントで実行する JavaScript を指定してください ("" を指定するとイベントを削除します) +@msg give `text` (the reading) or `remove` `text` (読み) または `remove` を指定してください +@msg give `x` and `y` (and a `tolerance` of 0 or more) `x` と `y` (および 0 以上の `tolerance`) を指定してください +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments mode、または r/g/b、c/m/y/k、gray、tint の調整値を指定してください +@msg give a name, or paint the active proxy with a saved preset 名前を指定するか、保存済みのプリセットでアクティブなプロキシをペイントしてください +@msg give a name, or select a stroke with a saved profile 名前を指定するか、保存済みのプロファイルが適用された線を選択してください +@msg give a pattern name or select an object painted with a pattern パターン名を指定するか、パターンでペイントしたオブジェクトを選択してください +@msg give at least one of clip, invert, disabled, linked clip、invert、disabled、linked のうち 1 つ以上を指定してください +@msg give color, none, swatch or gradient color、none、swatch、gradient のいずれかを指定してください +@msg give dataBase64 dataBase64 を指定してください +@msg give either at (a click) or area (a path drawn around the overlaps) at (クリック位置) または area (重なりを囲むように描いたパス) のどちらかを指定してください +@msg give formats or a preset, not both formats またはプリセットのどちらか一方を指定してください +@msg give newName or delete newName または delete を指定してください +@msg give offset or by offset または by を指定してください +@msg give one of `to`, `rect` or `at` `to`、`rect`、`at` のいずれかを指定してください +@msg give one of assets, layers, selection or fullDocument assets、layers、selection、fullDocument のいずれかを指定してください +@msg give only one of onto, above, below, icon and a column place onto、above、below、icon、列の位置のうち 1 つだけを指定してください +@msg give origin: [x, y], or reset: true origin: [x, y] または reset: true を指定してください +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note overprintFill、overprintStroke、showCenter、imageMap、url、note のいずれかを指定してください +@msg give path (a file) or folder path (ファイル) または folder を指定してください +@msg give path, or name and dataBase64 path、または name と dataBase64 を指定してください +@msg give paths or files paths または files を指定してください +@msg give rows, rowHeight, columns or columnWidth rows、rowHeight、columns、columnWidth のいずれかを指定してください +@msg give svg, or dataBase64 of an SVG or SVGZ file svg、または SVG か SVGZ ファイルの dataBase64 を指定してください +@msg give text text を指定してください +@msg give the asset a name アセットに名前を指定してください +@msg give the preset a `name` プリセットに `name` を指定してください +@msg give the preset a name プリセットに名前を指定してください +@msg give the preset a name (1–64 characters) プリセットに名前を指定してください (1–64 文字) +@msg give the settings to change, e.g. {"formats"}]} 変更する設定を指定してください (例: {"formats"}]}) +@msg give the style a name スタイルに名前を指定してください +@msg give two or more style `names` スタイルの `names` を 2 つ以上指定してください +@msg give two or more swatches スウォッチを 2 つ以上指定してください +@msg give x, y, or a handle's length or angle x、y、またはハンドルの length か angle を指定してください +@msg give {key, value} or {values} {key, value} または {values} を指定してください +@msg global must be true or false global には true または false を指定してください +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) この文字のスタイル (オールキャップス、スモールキャップ、数字の体系など) では、{ch} にグリフ {_1} は使用されません +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} グリフ {g} は {_1} {_2} における {ch} の代替字形ではありません +@msg gradient must be "objects" or "mesh" gradient には "objects" または "mesh" を指定してください +@msg grays must be "profile" or "black", not `{g}` grays には "profile" または "black" を指定してください (`{g}` は指定できません) +@msg gridColors must be a preset name or two colours gridColors にはプリセット名または 2 つのカラーを指定してください +@msg gridSize must be small, medium or large gridSize には small、medium、large のいずれかを指定してください +@msg guide {g} is not selected ガイド {g} は選択されていません +@msg handle must be 0..3 handle には 0..3 の値を指定してください +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (初期設定)、"omit"、"visible" のいずれかを指定してください +@msg highlight a layer to merge into 結合先のレイヤーをハイライトしてください +@msg highlight two or more rows in one layer or group 1 つのレイヤーまたはグループ内の行を 2 つ以上ハイライトしてください +@msg highlight two or more rows to merge 結合する行を 2 つ以上ハイライトしてください +@msg icon docks a panel in the icon column: use window.panel.dock icon はパネルをアイコン列にドッキングします: window.panel.dock を使用してください +@msg id must be a dimension id id には寸法の ID を指定してください +@msg image has no bounds 画像に境界がありません +@msg image {_1} is linked already 画像 {_1} はすでにリンクされています +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none、client、server のいずれかを指定してください +@msg index out of range インデックスが範囲外です +@msg instances must be 1..1000 instances には 1..1000 の値を指定してください +@msg invalid `color` `color` が無効です +@msg invalid anchor index {v}: expected a non-negative integer アンカーのインデックス {v} が無効です: 0 以上の整数を指定してください +@msg invalid anchor reference {v}: expected [subpath, anchor] アンカーの参照 {v} が無効です: [subpath, anchor] を指定してください +@msg invalid angle 角度が無効です +@msg invalid brush: {e} ブラシが無効です: {e} +@msg invalid color カラーが無効です +@msg invalid colour {c} カラー {c} が無効です +@msg invalid delta 差分が無効です +@msg invalid point 点が無効です +@msg invalid size サイズが無効です +@msg invalid t t が無効です +@msg invalid tolerance 許容値が無効です +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) アートボードがすべてキャンバスの外にあります (原点から {_1} pt を超えています) +@msg its clipping masks are nested too deeply クリッピングマスクの入れ子が深すぎます +@msg its metadata has no description メタデータに説明がありません +@msg its metadata has no direct length メタデータに直接指定された長さがありません +@msg join two different paths 異なる 2 つのパスを連結してください +@msg justify must be auto|left|center|right|justifyAll justify には auto|left|center|right|justifyAll のいずれかを指定してください +@msg kbps must be a positive number, not {kbps} kbps には正の数値を指定してください ({kbps} は指定できません) +@msg keywords must be a list of words or a comma-separated string keywords には単語のリストまたはカンマ区切りの文字列を指定してください +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords には制御文字を含まない {MAX_KEYWORD} 文字以内の値を指定してください +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind には 1、2、3 のいずれかを指定するか、プリセットの `name` を指定してください (perspective.presets.list を参照) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind には graphic、fillColor、strokeColor、fillGradient、strokeGradient、fillPattern、strokePattern、charStyle、paraStyle のいずれかを指定してください +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout には "on"、"off"、"neutral"、またはブール値を指定してください ({v} は指定できません) +@msg layer `{name}` has no art to export レイヤー `{name}` に書き出せるアートがありません +@msg layer must name a layer or sublayer layer にはレイヤーまたはサブレイヤーを指定してください +@msg layers can't be intertwined: select the objects in them レイヤーを絡み合わせることはできません: レイヤー内のオブジェクトを選択してください +@msg layers go only at the top level or in layers, objects in layers and groups レイヤーを置けるのは最上位またはレイヤー内だけで、オブジェクトを置けるのはレイヤー内とグループ内です +@msg layers must be true or false layers には true または false を指定してください +@msg layout must be gridByRow, gridByColumn, row or column layout には gridByRow、gridByColumn、row、column のいずれかを指定してください +@msg leading must be a number or "auto" leading には数値または "auto" を指定してください +@msg left/right must be non-negative widths left/right には 0 以上の幅を指定してください +@msg level `{t}`: 2 or 3 level `{t}`: 2 または 3 を指定してください +@msg line {line}: more than {MAX_CSV_COLS} columns {line} 行目: 列が {MAX_CSV_COLS} 個を超えています +@msg line {line}: unclosed quote {line} 行目: 引用符が閉じられていません +@msg line {line}: value for `{var}` must match its variable kind {line} 行目: `{var}` の値は変数の種類に合わせてください +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf または crlf を指定してください +@msg loading profiles from disk isn't available in the browser ブラウザーではディスクからプロファイルを読み込めません +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: マージンが {m} pt では用紙にプリント可能な領域が残りません +@msg matrix must be finite and invertible, depth finite matrix には有限で逆行列を持つ値、depth には有限の値を指定してください +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member は order が front、forward、backward、back のいずれかの場合に指定します +@msg missing `artboards` `artboards` がありません +@msg missing `at` [x, y] `at` [x, y] がありません +@msg missing `attrs` `attrs` がありません +@msg missing `color` or `opacity` `color` または `opacity` がありません +@msg missing `colors` `colors` がありません +@msg missing `commands` `commands` がありません +@msg missing `destinationFolder` `destinationFolder` がありません +@msg missing `effect` id `effect` の ID がありません +@msg missing `family` `family` がありません +@msg missing `find` `find` がありません +@msg missing `gid` (a glyph id, or null for the default glyph) `gid` がありません (グリフ ID、または初期設定のグリフにする場合は null) +@msg missing `group` `group` がありません +@msg missing `id` `id` がありません +@msg missing `id`: give one, or select one type object `id` がありません: 指定するか、文字オブジェクトを 1 つ選択してください +@msg missing `index` (a point index, or null to clear) `index` がありません (ポイントのインデックス、または消去する場合は null) +@msg missing `index` (a stop index, or null to clear) `index` がありません (分岐点のインデックス、または消去する場合は null) +@msg missing `index` (an item index or null) `index` がありません (項目のインデックスまたは null) +@msg missing `item` `item` がありません +@msg missing `library` `library` がありません +@msg missing `library` (see graphicStyle.libraries) `library` がありません (graphicStyle.libraries を参照) +@msg missing `library` (see swatch.library.list) `library` がありません (swatch.library.list を参照) +@msg missing `map` (rows [{from, to}] or {current}) `map` がありません (行 [{from, to}] または {current}) +@msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] がありません +@msg missing `name` (see gradient.presets.list) `name` がありません (gradient.presets.list を参照) +@msg missing `offset` (a distance in points) `offset` がありません (ポイント単位の距離) +@msg missing `path` `path` がありません +@msg missing `path` (a path object id) `path` がありません (パスオブジェクトの ID) +@msg missing `rule` `rule` がありません +@msg missing `source` id `source` の ID がありません +@msg missing `start` (the byte offset of a character) `start` がありません (文字のバイトオフセット) +@msg missing `status`: linked, embedded, missing or modified `status` がありません: linked、embedded、missing、modified のいずれかを指定してください +@msg missing `target` id `target` の ID がありません +@msg missing `text` `text` がありません +@msg missing `to` `to` がありません +@msg missing `to` (an index) `to` がありません (インデックス) +@msg missing `to`: a non-negative integer `to` がありません: 0 以上の整数を指定してください +@msg missing `type` `type` がありません +@msg missing `value` `value` がありません +@msg missing `{key}` `{key}` がありません +@msg missing `{k}` `{k}` がありません +@msg missing anchor anchor がありません +@msg missing anchors anchors がありません +@msg missing anchors [[subpath, anchor]…] anchors [[subpath, anchor]…] がありません +@msg missing array `{key}` 配列 `{key}` がありません +@msg missing case case がありません +@msg missing color color がありません +@msg missing commands commands がありません +@msg missing destination folder 出力先フォルダーがありません +@msg missing from from がありません +@msg missing group group がありません +@msg missing id or ids id または ids がありません +@msg missing ids ids がありません +@msg missing indexes indexes がありません +@msg missing matrix [a,b,c,d,e,f] matrix [a,b,c,d,e,f] がありません +@msg missing name name がありません +@msg missing op op がありません +@msg missing or invalid `color` `color` がないか、無効です +@msg missing or unknown `tool` `tool` がないか、不明です +@msg missing point point がありません +@msg missing point [x,y] point [x,y] がありません +@msg missing point `{key}` ([x, y]) 点 `{key}` ([x, y]) がありません +@msg missing points points がありません +@msg missing points [[x,y]…] points [[x,y]…] がありません +@msg missing segment segment がありません +@msg missing stops stops がありません +@msg missing subpaths subpaths がありません +@msg missing target target がありません +@msg missing to to がありません +@msg missing x, y x, y がありません +@msg mode is set, add or toggle, not `{other}` mode には set、add、toggle のいずれかを指定してください (`{other}` は指定できません) +@msg mode must be "cmyk" or "rgb" mode には "cmyk" または "rgb" を指定してください +@msg mode must be area|onPath, got {o} mode には area|onPath のいずれかを指定してください (指定値: {o}) +@msg more than 10000 linked files: package dependencies in smaller parts リンクファイルが 10000 個を超えています: 依存ファイルを小分けにしてパッケージしてください +@msg more than 256 commands コマンドが 256 個を超えています +@msg more than {MAX_BATCH_FILES} data sets データセットが {MAX_BATCH_FILES} 個を超えています +@msg more than {MAX_BATCH_FILES} files ファイルが {MAX_BATCH_FILES} 個を超えています +@msg more than {MAX_CSV_ROWS} data rows データ行が {MAX_CSV_ROWS} 行を超えています +@msg more than {MAX_FILES} images 画像が {MAX_FILES} 個を超えています +@msg more than {MAX_IMPORT} presets プリセットが {MAX_IMPORT} 個を超えています +@msg moved must be [[x,y]…] moved には [[x,y]…] を指定してください +@msg name (a toolbar's name) is required name (ツールバー名) は必須です +@msg name (a toolbar's name) must be a string name (ツールバー名) には文字列を指定してください +@msg name must be a toolbar's name name にはツールバー名を指定してください +@msg names is empty names が空です +@msg names must be an array of symbol names names にはシンボル名の配列を指定してください +@msg need `anchor` or `segment` + `t` `anchor`、または `segment` + `t` が必要です +@msg need at least one anchor アンカーが 1 つ以上必要です +@msg need at least one point 点が 1 つ以上必要です +@msg need at least two points 点が 2 つ以上必要です +@msg needs points 点が必要です +@msg nineSlice must be true or false nineSlice には true または false を指定してください +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides には [left, top, right, bottom] を指定してください。各値は -16..16 で、left + right < 1 かつ top + bottom < 1 にしてください +@msg no PDF preset named `{n}` `{n}` という名前の PDF プリセットはありません +@msg no action set is current 現在のアクションセットがありません +@msg no active data set アクティブなデータセットがありません +@msg no appearance item {index} アピアランス項目 {index} はありません +@msg no artboard {_1} アートボード {_1} はありません +@msg no artboard {artboard} (0-based; the document has {_1}) アートボード {artboard} はありません (0 から始まるインデックス。ドキュメントのアートボードは {_1} 個) +@msg no asset {id} アセット {id} はありません +@msg no asset {id} (see assets.list) アセット {id} はありません (assets.list を参照) +@msg no assets to export 書き出すアセットがありません +@msg no assets yet: collect art for export first (assets.add) アセットがまだありません: 先に書き出すアートを収集してください (assets.add) +@msg no brush named `{n}` `{n}` という名前のブラシはありません +@msg no character at byte {start} バイト {start} の位置に文字はありません +@msg no colour カラーがありません +@msg no colour group `{group}` カラーグループ `{group}` はありません +@msg no colour group `{name}` カラーグループ `{name}` はありません +@msg no data sets データセットがありません +@msg no dataset named `{from}` `{from}` という名前のデータセットはありません +@msg no dataset named `{name}` `{name}` という名前のデータセットはありません +@msg no datasets データセットがありません +@msg no design named `{name}` `{name}` という名前のデザインはありません +@msg no design named `{n}` `{n}` という名前のデザインはありません +@msg no design named `{old}` `{old}` という名前のデザインはありません +@msg no encoder for {_1} yet {_1} のエンコーダーはまだありません +@msg no export preset `{name}` (see document.exportPresets.list) 書き出しプリセット `{name}` はありません (document.exportPresets.list を参照) +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder フォルダーがありません: `path` を指定するか、「環境設定 › プラグイン & 仮想記憶ディスク › 追加プラグインフォルダー」を設定してください +@msg no fonts to look for: they are all available 検索するフォントがありません: すべて使用できます +@msg no formats to export 書き出す形式がありません +@msg no gradient preset `{name}` (see gradient.presets.list) グラデーションプリセット `{name}` はありません (gradient.presets.list を参照) +@msg no graphic style `{n}` in `{_1}` `{_1}` にグラフィックスタイル `{n}` はありません +@msg no graphic style library `{key}` (see graphicStyle.libraries) グラフィックスタイルライブラリ `{key}` はありません (graphicStyle.libraries を参照) +@msg no guide selected ガイドが選択されていません +@msg no guide selected (or give an index) ガイドが選択されていません (またはインデックスを指定してください) +@msg no guide {i} ガイド {i} はありません +@msg no images in the folder フォルダーに画像がありません +@msg no item at index {_1} インデックス {_1} に項目はありません +@msg no item at index {from} インデックス {from} に項目はありません +@msg no item at one of the indices {idx} インデックス {idx} のいずれかに項目がありません +@msg no layer レイヤーがありません +@msg no layer {id} (see layer.list) レイヤー {id} はありません (layer.list を参照) +@msg no layers to export 書き出すレイヤーがありません +@msg no library `{key}` ライブラリ `{key}` はありません +@msg no metafile format メタファイル形式はありません +@msg no object {_1} オブジェクト {_1} はありません +@msg no objects オブジェクトがありません +@msg no parent layer 親レイヤーがありません +@msg no perspective grid preset named `{b}` (see perspective.presets.list) `{b}` という名前の遠近グリッドプリセットはありません (perspective.presets.list を参照) +@msg no perspective grid preset named `{n}` `{n}` という名前の遠近グリッドプリセットはありません +@msg no point selected (give `index`) ポイントが選択されていません (`index` を指定してください) +@msg no preset called `{from}` (see file.newPresets) `{from}` というプリセットはありません (file.newPresets を参照) +@msg no preset called `{name}` (see file.newPresets) `{name}` というプリセットはありません (file.newPresets を参照) +@msg no preset named `{n}` `{n}` という名前のプリセットはありません +@msg no print preset named `{b}` (see print.presets.list) `{b}` という名前のプリントプリセットはありません (print.presets.list を参照) +@msg no print preset named `{name}` (presets: {_1}) `{name}` という名前のプリントプリセットはありません (プリセット: {_1}) +@msg no print preset named `{n}` `{n}` という名前のプリントプリセットはありません +@msg no profile named `{name}` `{name}` という名前のプロファイルはありません +@msg no readable files in the folder フォルダーに読み込めるファイルがありません +@msg no recovery copy `{f}` (see file.recovery.list) 復元用コピー `{f}` はありません (file.recovery.list を参照) +@msg no rows 行がありません +@msg no saved PDF preset named `{name}` `{name}` という名前の保存済み PDF プリセットはありません +@msg no saved export preset `{name}` 保存済みの書き出しプリセット `{name}` はありません +@msg no saved gradient preset called {name} {name} という保存済みのグラデーションプリセットはありません +@msg no saved gradient presets 保存済みのグラデーションプリセットがありません +@msg no saved preset called `{name}` `{name}` という保存済みのプリセットはありません +@msg no saved presets to export (name [Default] in `names` to export it) 書き出す保存済みプリセットがありません ([Default] を書き出すには `names` に指定してください) +@msg no saved presets to export (name built-in ones in `names`) 書き出す保存済みプリセットがありません (組み込みのプリセットは `names` に指定してください) +@msg no saved print preset named `{name}` `{name}` という名前の保存済みプリントプリセットはありません +@msg no saved profile called {name} {name} という保存済みのプロファイルはありません +@msg no saved selection `{name}` 保存した選択範囲 `{name}` はありません +@msg no saved theme called {name} (see colorTheme.list) {name} という保存済みのテーマはありません (colorTheme.list を参照) +@msg no saved views 保存した表示がありません +@msg no series {i} 系列 {i} はありません +@msg no slice {n} in the image (see slice.list) 画像にスライス {n} はありません (slice.list を参照) +@msg no source art 元のアートがありません +@msg no stop {i} (the gradient has {n}) 分岐点 {i} はありません (グラデーションの分岐点は {n} 個) +@msg no style `{name}` スタイル `{name}` はありません +@msg no such assets (see assets.list) 該当するアセットはありません (assets.list を参照) +@msg no such frame anchor, or the frame would reach past the canvas 該当するフレームのアンカーがないか、フレームがキャンバスからはみ出します +@msg no such objects 該当するオブジェクトはありません +@msg no such path anchor, or the path would reach past the canvas 該当するパスのアンカーがないか、パスがキャンバスからはみ出します +@msg no such rows 該当する行はありません +@msg no swatch `{keep}` スウォッチ `{keep}` はありません +@msg no swatch `{n}` スウォッチ `{n}` はありません +@msg no swatch `{n}` (swatches and colour groups move separately) スウォッチ `{n}` はありません (スウォッチとカラーグループは別々に移動します) +@msg no swatch library `{key}` (see swatch.library.list) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照。"document" はドキュメントのスウォッチ) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照。"{DOCUMENT_SWATCHES}" はドキュメントのスウォッチ) +@msg no swatch named `{n}` `{n}` という名前のスウォッチはありません +@msg no swatch or colour group `{n}` スウォッチまたはカラーグループ `{n}` はありません +@msg no swatch or colour group `{n}` in `{_1}` `{_1}` にスウォッチまたはカラーグループ `{n}` はありません +@msg no symbol named `{name}` `{name}` という名前のシンボルはありません +@msg no text objects selected テキストオブジェクトが選択されていません +@msg no toolbar named `{_1}` `{_1}` という名前のツールバーはありません +@msg no toolbar named `{name}` `{name}` という名前のツールバーはありません +@msg no tracing preset `{preset}` (see imageTrace.presets) トレースプリセット `{preset}` はありません (imageTrace.presets を参照) +@msg no user library folder here (save with a path, or without one to get the data) ここにはユーザーライブラリフォルダーがありません (パスを指定して保存するか、パスを指定せずにデータを取得してください) +@msg no variable named `{from}` `{from}` という名前の変数はありません +@msg no variable named `{name}` `{name}` という名前の変数はありません +@msg no variable named `{var}` `{var}` という名前の変数はありません +@msg no variable named `{v}` `{v}` という名前の変数はありません +@msg no view named `{name}` `{name}` という名前の表示はありません +@msg node {_1} is not a graph ノード {_1} はグラフではありません +@msg node {_1} is not an image ノード {_1} は画像ではありません +@msg node {_1} is not type ノード {_1} は文字ではありません +@msg not a PDF presets file (format `{_1}`) PDF プリセットファイルではありません (形式 `{_1}`) +@msg not a PDF presets file: {e} PDF プリセットファイルではありません: {e} +@msg not a Word document: its WordDocument stream doesn't start with a file information block Word 文書ではありません: WordDocument ストリームがファイル情報ブロックで始まっていません +@msg not a Word document: its compound file has no WordDocument stream Word 文書ではありません: 複合ファイルに WordDocument ストリームがありません +@msg not a Word document: its main part is not a WordprocessingML document Word 文書ではありません: メインパートが WordprocessingML 文書ではありません +@msg not a Word document: its package has no main document part Word 文書ではありません: パッケージにメイン文書パートがありません +@msg not a closed path クローズパスではありません +@msg not a flattener presets file (format `{_1}`) 分割・統合プリセットファイルではありません (形式 `{_1}`) +@msg not a flattener presets file: {e} 分割・統合プリセットファイルではありません: {e} +@msg not a layer or group レイヤーでもグループでもありません +@msg not a library file: {e} ライブラリファイルではありません: {e} +@msg not a mesh envelope point メッシュエンベロープのポイントではありません +@msg not a perspective grid presets file (format `{_1}`) 遠近グリッドプリセットファイルではありません (形式 `{_1}`) +@msg not a perspective grid presets file: {e} 遠近グリッドプリセットファイルではありません: {e} +@msg not a print presets file (format `{_1}`) プリントプリセットファイルではありません (形式 `{_1}`) +@msg not a print presets file: {e} プリントプリセットファイルではありません: {e} +@msg not a stored or deflated central record 無圧縮または Deflate 圧縮のセントラルレコードではありません +@msg not a valid SVGZ file: {e} 有効な SVGZ ファイルではありません: {e} +@msg not an RTF file: it doesn't start with {\\rtf RTF ファイルではありません: {\\rtf で始まっていません +@msg not an image VectorCraft reads (see document.formats) VectorCraft で読み込める画像ではありません (document.formats を参照) +@msg not area type (text in a frame) outside perspective 遠近グリッド外のエリア内文字 (フレーム内のテキスト) ではありません +@msg not editing an opacity mask 不透明マスクを編集中ではありません +@msg not type on a path outside perspective 遠近グリッド外のパス上文字ではありません +@msg nothing prints プリントされるものがありません +@msg nothing to bind バインドする対象がありません +@msg nothing to change 変更する内容がありません +@msg nothing to change: give dx, dy, hScale, vScale or rotation 変更する内容がありません: dx、dy、hScale、vScale、rotation のいずれかを指定してください +@msg nothing to crop 切り抜く対象がありません +@msg nothing to export 書き出す対象がありません +@msg nothing to export: the document has no visible art 書き出す対象がありません: ドキュメントに表示されているアートがありません +@msg nothing to mark マークする対象がありません +@msg nothing to print: every artboard picked is blank プリントする対象がありません: 選択したアートボードがすべて空白です +@msg nothing to print: the document has no art that prints プリントする対象がありません: ドキュメントにプリントされるアートがありません +@msg object is not a path オブジェクトがパスではありません +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object オブジェクトの透明は、ハイライトした文字には適用できません。「スポイトオプション」の「透明」をオフにするか、オブジェクト全体を選択してください +@msg object {_1} can't be set as point type オブジェクト {_1} はポイント文字にできません +@msg object {_1} has lost its file's contents: relink it オブジェクト {_1} のファイルの内容が失われています: 再リンクしてください +@msg object {_1} has no path オブジェクト {_1} にパスがありません +@msg object {_1} has no size オブジェクト {_1} にサイズがありません +@msg object {_1} is hidden or locked, or on a locked artboard オブジェクト {_1} は非表示かロックされているか、ロックされたアートボード上にあります +@msg object {_1} is not a placed document オブジェクト {_1} は配置したドキュメントではありません +@msg object {_1} is not an Intertwine group オブジェクト {_1} は織り交ぜグループではありません +@msg object {_1} is not an image オブジェクト {_1} は画像ではありません +@msg object {_1} is not an image or a linked file オブジェクト {_1} は画像でもリンクファイルでもありません +@msg object {_1} is not an image or a placed document オブジェクト {_1} は画像でも配置したドキュメントでもありません +@msg object {_1} is not in this Intertwine group オブジェクト {_1} はこの織り交ぜグループに含まれていません +@msg object {_1} needs a 3D effect at index {index} オブジェクト {_1} にはインデックス {index} に 3D 効果が必要です +@msg object {id} has anchors 0–{_1} for `corners`, not {past} オブジェクト {id} で `corners` に指定できるアンカーは 0–{_1} です ({past} は指定できません) +@msg object {id} has {n} anchors, so no anchor {past} オブジェクト {id} のアンカーは {n} 個のため、アンカー {past} はありません +@msg objects must be none, move or copy objects には none、move、copy のいずれかを指定してください +@msg objects were left out 除外されたオブジェクトがあります +@msg offset out of range オフセットが範囲外です +@msg on must be true or false on には true または false を指定してください +@msg only groups, layers, type and images have a Contents (Characters) row 「内容 (文字)」行があるのは、グループ、レイヤー、文字、画像だけです +@msg only interior mesh lines can be deleted 削除できるのは内側のメッシュラインだけです +@msg only solid colours can be added (not gradients or patterns) 追加できるのは単色だけです (グラデーションとパターンは追加できません) +@msg only solid-colour swatches can be spot colours 特色にできるのは単色のスウォッチだけです +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first この Affinity ファイルは埋め込みプレビューしか読み込めませんでした: 「ファイル › 開く」で警告とともに表示するか、先に Affinity から SVG または PDF を書き出してください +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning この Affinity ファイルは埋め込みプレビューしか読み込めませんでした。「ファイル › 開く」で警告とともに表示できます +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto、above、below、icon、列の位置は window.panel.float と window.panel.dock で指定します +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto、above、below、icon、列の位置を指定する場合、anchor、side、zone、before は指定できません +@msg onto, above, below, icon and a column place take no x, y, width or height onto、above、below、icon、列の位置を指定する場合、x、y、width、height は指定できません +@msg open a document to look for its linked files リンクファイルを検索するには、ドキュメントを開いてください +@msg options must be an object (see file.formatOptions) options にはオブジェクトを指定してください (file.formatOptions を参照) +@msg order must be leftToRight or rightToLeft order には leftToRight または rightToLeft を指定してください +@msg orientation must be portrait or landscape, not `{o}` orientation には portrait または landscape を指定してください (`{o}` は指定できません) +@msg outlineText {v}: true or false outlineText {v}: true または false を指定してください +@msg output intent profile: {e} 出力インテントのプロファイル: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: 「{name}」という名前の RGB または CMYK プロファイルはありません +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} ファイルは CMYK ですが、「{name}」は RGB プロファイルです +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: プリントするように設定されたインキがありません +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: 「{name}」という名前のプロファイルはありません。{std} の出力インテントにはプロファイルを埋め込みます +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: 「{name}」という名前のプロファイルはありません: {std} の出力インテントには、プロファイルを埋め込むか、登録済みの印刷条件 (outputConditionId と registry) を指定します +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: {std} ファイルは CMYK でプリントされますが、「{name}」は RGB プロファイルです +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: 「{name}」はグレープロファイルのため、{std} の出力インテントにはできません +@msg overlap: {o} pt is half a tile or more overlap: {o} pt はタイルの半分以上です +@msg overlapping Shaper paths must be in the same layer or group 重なり合う Shaper パスは、同じレイヤーまたはグループ内にある必要があります +@msg overlapping edits 編集範囲が重なっています +@msg page art conflicts with image data for {key} ページのアートが {key} の画像データと一致しません +@msg page art conflicts with pattern {name} ページのアートがパターン {name} と一致しません +@msg page art conflicts with swatch {name} ページのアートがスウォッチ {name} と一致しません +@msg page art is missing image data for {key} ページのアートに {key} の画像データがありません +@msg page art is missing pattern {name} ページのアートにパターン {name} がありません +@msg page art is missing swatch {name} ページのアートにスウォッチ {name} がありません +@msg page must be a whole number from 1 page には 1 以上の整数を指定してください +@msg page thumbnail: {e} ページのサムネール: {e} +@msg page {page} of the PDF has no objects PDF の {page} ページにオブジェクトがありません +@msg page {page}: `{file}` has {n} {artboards} {page} ページ: `{file}` には {n} {artboards} あります +@msg page {page}: `{name}` has {n} {pages} {page} ページ: `{name}` には {n} {pages} あります +@msg pages must be a range such as "2-3" or a page number, not {v} pages には "2-3" のような範囲またはページ番号を指定してください ({v} は指定できません) +@msg pages: {e} pages: {e} +@msg panel position must be finite パネルの位置には有限の値を指定してください +@msg parameters must be an object parameters にはオブジェクトを指定してください +@msg params must be an object params にはオブジェクトを指定してください +@msg passwords can have up to {MAX_PASSWORD} bytes パスワードは {MAX_PASSWORD} バイト以内です +@msg path has no open end パスに開いた端点がありません +@msg paths must be strings paths には文字列を指定してください +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false の場合、編集可能なアートワークを保持するためにすべてのアートボードが必要です +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single または multiple を指定してください +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom には "artwork" または "swatches" を指定してください ({v} は指定できません) +@msg pins and moved must be non-empty lists of the same length pins と moved には、同じ長さの空でないリストを指定してください +@msg pins must be [[x,y]…] pins には [[x,y]…] を指定してください +@msg pins must be finite pins には有限の値を指定してください +@msg place is above, below or inside, not `{other}` place には above、below、inside のいずれかを指定してください (`{other}` は指定できません) +@msg plane must be left|right|ground plane には left|right|ground のいずれかを指定してください +@msg plane must be left|right|ground|none plane には left|right|ground|none のいずれかを指定してください +@msg point is outside the mesh 点がメッシュの外にあります +@msg points must be [t, left, right] with non-negative widths points には、幅が 0 以上の [t, left, right] を指定してください +@msg points must be a non-empty [[x,y,pressure?]…] list points には空でない [[x,y,pressure?]…] のリストを指定してください +@msg points must be a non-empty list of finite [x,y] points には有限の [x,y] からなる空でないリストを指定してください +@msg points must be an array or null points には配列または null を指定してください +@msg points must be finite points には有限の値を指定してください +@msg points must be finite [x,y] pairs points には有限の [x,y] の組を指定してください +@msg points must contain 1–4096 coordinates points には 1–4096 個の座標を指定してください +@msg position must be normal, superscript or subscript position には normal、superscript、subscript のいずれかを指定してください +@msg position must be topLeft, topRight, bottomLeft or bottomRight position には topLeft、topRight、bottomLeft、bottomRight のいずれかを指定してください +@msg positions must be between 0 and 100000 pt positions には 0 から 100000 pt までの値を指定してください +@msg ppi must be a positive number, not {ppi} ppi には正の数値を指定してください ({ppi} は指定できません) +@msg ppi must be between 1 and 2400 ppi には 1 から 2400 までの値を指定してください +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` は transforms|bounds|fileDimensions|fit|fill のいずれでもありません +@msg preserveShape must be clippingMask|transparency preserveShape には clippingMask|transparency のいずれかを指定してください +@msg preset `{id}`: mobile or density preset `{id}`: mobile または density を指定してください +@msg preset must be standard, diffuse, topLeft or right preset には standard、diffuse、topLeft、right のいずれかを指定してください +@msg preset must be the name of a print preset preset にはプリントプリセットの名前を指定してください +@msg previewMode must be default, pixel or overprint previewMode には default、pixel、overprint のいずれかを指定してください +@msg print settings: {e} プリント設定: {e} +@msg procedures nest too deeply プロシージャの入れ子が深すぎます +@msg procedures nested too deeply プロシージャの入れ子が深すぎます +@msg quotes must be {double?, single?} quotes には {double?, single?} を指定してください +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} には開き引用符と閉じ引用符を指定してください (例: "“”") +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi には 1–2400 を指定してください (72 がスクリーン、150 が標準、300 が高解像度) +@msg rating must be a whole number from 0 to {MAX_RATING} rating には 0 から {MAX_RATING} までの整数を指定してください +@msg rect and {k} contradict each other: give the area or the artboards rect と {k} が矛盾しています: 領域またはアートボードのどちらかを指定してください +@msg rect applies to raster formats, not {_1} rect はラスター形式に適用されます ({_1} には適用されません) +@msg rect is [x, y, width, height] in points rect には [x, y, width, height] をポイント単位で指定してください +@msg rect must be [x, y, width, height] rect には [x, y, width, height] を指定してください +@msg rect must be [x, y, width, height] with a positive width and height rect には、幅と高さが正の [x, y, width, height] を指定してください +@msg rect needs finite numbers and a positive width and height rect には有限の数値と、正の幅と高さが必要です +@msg redraw needs id redraw には id が必要です +@msg redraw within must be a finite distance of 0 or more redraw の within には 0 以上の有限の距離を指定してください +@msg registration must be 0..8 registration には 0..8 の値を指定してください +@msg release a layer or a group レイヤーまたはグループを解除してください +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace では置き換えられるオブジェクトの位置と変形が保持されます: template、at、rect は指定しないでください +@msg replace: select the one object to replace replace: 置き換えるオブジェクトを 1 つ選択してください +@msg resolution must be a number or screen|medium|high resolution には数値、または screen|medium|high のいずれかを指定してください +@msg resolution must be between 1 and 2400 ppi resolution には 1 から 2400 ppi までの値を指定してください +@msg rotation must be a number of degrees rotation には度数の数値を指定してください +@msg rotation must be finite rotation には有限の値を指定してください +@msg rows and cols must be 1..50 rows と cols には 1..50 の値を指定してください +@msg rows and columns must be 1..500 rows と columns には 1..500 の値を指定してください +@msg rows of objects whose appearances differ can't be reordered together アピアランスが異なるオブジェクトの行は、まとめて並べ替えられません +@msg save the document first: Package collects a saved document 先にドキュメントを保存してください: パッケージは保存済みのドキュメントを収集します +@msg save the document first: copy puts the file in its folder 先にドキュメントを保存してください: copy はファイルをドキュメントのフォルダーに置きます +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: 2x のような倍率、幅 (100w)、高さ (100h)、解像度 (72ppi) のいずれかを指定してください +@msg scale must be a number above 0, at most 64 scale には 0 より大きく 64 以下の数値を指定してください +@msg scale must be non-zero scale には 0 以外の値を指定してください +@msg scope must be selection or all (got {other}) scope には selection または all を指定してください (指定値: {other}) +@msg scribble is too long 落書きが長すぎます +@msg segment does not exist セグメントが存在しません +@msg select a Shaper group Shaper グループを選択してください +@msg select a blend to set easing or color shift イージングまたはカラーシフトを設定するブレンドを選択してください +@msg select a compound shape 複合シェイプを選択してください +@msg select a graph グラフを選択してください +@msg select a group or layer with visible, unlocked members 表示されていてロックされていないメンバーを含むグループまたはレイヤーを選択してください +@msg select a layer レイヤーを選択してください +@msg select a path パスを選択してください +@msg select a text object テキストオブジェクトを選択してください +@msg select an image (or an Image Trace group) 画像 (または画像トレースグループ) を選択してください +@msg select an image, or pass id 画像を選択するか、id を指定してください +@msg select an object (or pass `id`) オブジェクトを選択してください (または `id` を指定してください) +@msg select an object with a freeform gradient フリーフォームグラデーションのあるオブジェクトを選択してください +@msg select an object with an opacity mask 不透明マスクのあるオブジェクトを選択してください +@msg select area type エリア内文字を選択してください +@msg select area type (text in a frame) エリア内文字 (フレーム内のテキスト) を選択してください +@msg select area type and closed paths (at least one area type object) エリア内文字とクローズパスを選択してください (エリア内文字オブジェクトが 1 つ以上必要です) +@msg select area type whose first line can fit its frame 1 行目がフレームに収まるエリア内文字を選択してください +@msg select artwork to add its colours カラーを追加するアートワークを選択してください +@msg select artwork to make a group of its colours カラーのグループを作成するアートワークを選択してください +@msg select artwork to recolour, or give a colour `group` 再配色するアートワークを選択するか、カラーの `group` を指定してください +@msg select exactly two path ends to join 連結するパスの端点をちょうど 2 つ選択してください +@msg select members of a compound shape 複合シェイプのメンバーを選択してください +@msg select objects or give ids オブジェクトを選択するか、ids を指定してください +@msg select objects to align to the guide ガイドに整列するオブジェクトを選択してください +@msg select one Intertwine group 織り交ぜグループを 1 つ選択してください +@msg select one image or pass id 画像を 1 つ選択するか、id を指定してください +@msg select paths パスを選択してください +@msg select paths or compound paths パスまたは複合パスを選択してください +@msg select rows or objects to collect 収集する行またはオブジェクトを選択してください +@msg select something to export 書き出す対象を選択してください +@msg select something to export (or turn off selectedOnly) 書き出す対象を選択してください (または selectedOnly をオフにしてください) +@msg select text to redefine the style from スタイルの再定義に使用するテキストを選択してください +@msg select the art and, on top of it, the mask object アートと、その前面にあるマスクオブジェクトを選択してください +@msg select the art for the design デザインにするアートを選択してください +@msg select the art for the pattern tile パターンタイルにするアートを選択してください +@msg select the art to add first 先に追加するアートを選択してください +@msg select the art to collect (or give its ids) 収集するアートを選択してください (またはその ids を指定してください) +@msg select the content and a path on top 内容と、その前面にあるパスを選択してください +@msg select the images to relink, or pass ids 再リンクする画像を選択するか、ids を指定してください +@msg select the images, or pass ids 画像を選択するか、ids を指定してください +@msg select the linked images or placed documents, or pass ids リンク画像または配置したドキュメントを選択するか、ids を指定してください +@msg select the object to copy from コピー元のオブジェクトを選択してください +@msg select the objects (or give their ids) オブジェクトを選択してください (またはその ids を指定してください) +@msg select the objects to attach to the path パスに配置するオブジェクトを選択してください +@msg select the objects to bind バインドするオブジェクトを選択してください +@msg select two or more objects to intertwine 織り交ぜるオブジェクトを 2 つ以上選択してください +@msg select two or more slices to combine 結合するスライスを 2 つ以上選択してください +@msg select type on a path パス上文字を選択してください +@msg selection `{m}`: one or each selection `{m}`: one または each を指定してください +@msg settings is an object of document.exportForScreens params settings には document.exportForScreens のパラメーターのオブジェクトを指定してください +@msg settings must be an object settings にはオブジェクトを指定してください +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all、missing、modified、embedded のいずれかを指定してください +@msg show must be true or false show には true または false を指定してください +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size には "small"、"medium"、"large"、または 0 より大きく {_1} 以下のポイント数を指定してください +@msg size must be positive size には正の値を指定してください +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode には "fixed" または "pressure" を指定してください (`{other}` は指定できません) +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode には "fixed" または "pressure" を指定してください ({other} は指定できません) +@msg sizeVariation must be 0 or more sizeVariation には 0 以上の値を指定してください +@msg smallCaps must be true or false smallCaps には true または false を指定してください +@msg sort `{x}`: name, kind or status sort `{x}`: name、kind、status のいずれかを指定してください +@msg source paths are too large for Shaper 元パスが Shaper には大きすぎます +@msg spot, mode and group apply to solid colours only spot、mode、group を適用できるのは単色だけです +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked、embedded、missing、modified のいずれかを指定してください +@msg steps must be 1..1000 steps には 1..1000 の値を指定してください +@msg steps must be 1..{MAX_STEPS} steps には 1..{MAX_STEPS} の値を指定してください +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) 分岐点 {i}: `plates` には 0 から 1 までの数値を 4 つ指定してください (C, M, Y, K) +@msg stopped before the archive was finished アーカイブが完成する前に停止しました +@msg strokeMode must be within|along|across, got {m} strokeMode には within|along|across のいずれかを指定してください (指定値: {m}) +@msg strokeOptions must be an object of stroke.set options strokeOptions には stroke.set のオプションのオブジェクトを指定してください +@msg strokeWidth must be a positive number of pixels strokeWidth には正のピクセル数を指定してください +@msg subpath {si} is not an open subpath サブパス {si} はオープンなサブパスではありません +@msg subpaths required when joining the same path 同じパスを連結する場合は subpaths が必要です +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict には "merge" または "add" を指定してください ({v} は指定できません) +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text には {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} を指定してください +@msg text transform is collapsed or unrepresentable テキストの変形がつぶれているか、表現できません +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces には 2 から 100 までの整数を指定してください +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: "{_1}" または "{_2}" を使用してください +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: 画像ファイルの {path} または {name, dataBase64}、あるいは effect.list が返す形式のテクスチャを指定してください +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices スライスが {MAX_PIECES} 個を超えます +@msg that object is not in the overlap そのオブジェクトは重なりの中にありません +@msg the Affinity document has no page with a size Affinity ドキュメントにサイズのあるページがありません +@msg the CMYK image couldn't be written CMYK 画像を書き出せませんでした +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected PDF のセキュリティ設定はパスワードなしで保存されています: openPassword または permissionsPassword をもう一度指定するか、保護なしで書き出すには passwordRequired のない security を指定してください +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) RTF ファイルの段落または書式の変更が多すぎて、文字として配置できません (最大 {MAX_PARAGRAPHS} 段落、{MAX_RUNS} ラン) +@msg the RTF file is too large to place as type (at most {_1} MB) RTF ファイルが大きすぎて、文字として配置できません (最大 {_1} MB) +@msg the SVG expands to more than {0} nested SVG images SVG を展開すると、入れ子の SVG 画像が {0} 個を超えます +@msg the SVG expands to more than {0} objects SVG を展開すると、オブジェクトが {0} 個を超えます +@msg the SVG has no drawable objects SVG に描画できるオブジェクトがありません +@msg the SVGZ file unpacks to more than {_1} MB SVGZ ファイルを展開すると {_1} MB を超えます +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place Word 文書のプロパティが {MAX_PAGES} ページを超えており、多すぎて配置できません +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place Word 文書の文字または段落プロパティのランが {MAX_RUNS} 個を超えており、多すぎて配置できません +@msg the Word document has more than {_1} MB of formatting properties, too many to place Word 文書の書式プロパティが {_1} MB を超えており、多すぎて配置できません +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) Word 文書のテキストまたは書式が多すぎて、文字として配置できません (最大 {MAX_PARAGRAPHS} 段落、{MAX_RUNS} ラン、テキスト {_1} MB) +@msg the Word document has too much text to place as type (at most {_1} MB) Word 文書のテキストが多すぎて、文字として配置できません (最大 {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it Word 文書が暗号化されているか、パスワードで保護されています。配置するには、パスワードなしで保存してください +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) Word 文書が大きすぎて配置できません ({name} ストリームが {_1} MB を超えています) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off Word 文書は高速保存で保存されていますが、VectorCraft はこの形式を読み込めません。高速保存をオフにして保存し直してください +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document Word 文書は Word 6.0 または Word 95 で保存されていますが、VectorCraft はこの形式を読み込めません。Word 97-2003 文書として保存してください +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place Word 文書のテキストが {MAX_PIECES} 個を超えるピースに分かれており、多すぎて配置できません +@msg the action set ids are not a list アクションセットの ID がリストではありません +@msg the active paint is not a gradient アクティブなペイントがグラデーションではありません +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size アートボードがキャンバスに収まりません: アートボードの数を減らすか、間隔またはサイズを小さくしてください +@msg the character's size can't be measured 文字のサイズを計測できません +@msg the compound shape is too complex to expand 複合シェイプが複雑すぎて拡張できません +@msg the container of the listed objects is gone 指定したオブジェクトを含むコンテナがなくなりました +@msg the copy `{user_id}` can't be read as a library コピー `{user_id}` をライブラリとして読み込めません +@msg the copy would leave the canvas コピーがキャンバスからはみ出します +@msg the cut cannot be made on this art: {e} このアートはカットできません: {e} +@msg the data isn't an EMF or WMF picture データが EMF または WMF の画像ではありません +@msg the data isn't an EPS or PostScript picture データが EPS または PostScript の画像ではありません +@msg the deferred paste is no longer available 遅延ペーストは使用できなくなりました +@msg the design is too big to draw at every data point of this graph デザインが大きすぎて、このグラフのすべてのデータポイントには描画できません +@msg the design is too big to draw in every column of this graph デザインが大きすぎて、このグラフのすべての棒には描画できません +@msg the dialog it was opened over is no longer open 元のダイアログはすでに閉じられています +@msg the dictionary stack overflowed 辞書スタックがオーバーフローしました +@msg the editing data's hash has no place 編集データのハッシュを書き込む場所がありません +@msg the entities the document declares expand to too much text ドキュメントで宣言されたエンティティを展開すると、テキストが多くなりすぎます +@msg the envelope's content has no bounds エンベロープの内容に境界がありません +@msg the envelope's mesh is malformed エンベロープのメッシュの形式が正しくありません +@msg the file holds no `variableSets` element ファイルに `variableSets` 要素がありません +@msg the file is larger than {_1} MB ファイルが {_1} MB を超えています +@msg the file is too large ファイルが大きすぎます +@msg the file was updated or encrypted already ファイルはすでに更新または暗号化されています +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) 行の先頭の文字には、カーニングの対象となる前の文字がありません: 上下 (dy) にのみ移動します +@msg the font {_1} is not available フォント {_1} は使用できません +@msg the format has no file extension この形式にはファイル拡張子がありません +@msg the graphic can't be read: {e} グラフィックを読み込めません: {e} +@msg the graphic is empty グラフィックが空です +@msg the graphic's data is damaged グラフィックのデータが破損しています +@msg the graphic's document has no artboard グラフィックのドキュメントにアートボードがありません +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them `{name}` のヘッダー `{cell}` は、読み戻すと変数 `{other}` として扱われます: どちらかの名前を変更してください +@msg the image `{path}` isn't followed: {r} 画像 `{path}` は参照しません: {r} +@msg the image has no embedded pixels 画像に埋め込みピクセルがありません +@msg the image is empty 画像が空です +@msg the image is fully transparent 画像が完全に透明です +@msg the image is not on an artboard; pass rect 画像がアートボード上にありません。rect を指定してください +@msg the image's pixels are missing 画像のピクセルがありません +@msg the image's pixels aren't in the document 画像のピクセルがドキュメント内にありません +@msg the image's pixels can't be read: {e} 画像のピクセルを読み込めません: {e} +@msg the image's transparency doesn't fit it 画像の透明情報が画像と合っていません +@msg the job would print more than {MAX_PAGES} pages このジョブでは {MAX_PAGES} ページを超えてプリントされます +@msg the job would print more than {_1} pages このジョブでは {_1} ページを超えてプリントされます +@msg the layers hold no art to mask レイヤーにマスクするアートがありません +@msg the layout moved レイアウトが移動しました +@msg the library has a group `{name}` already ライブラリにはすでにグループ `{name}` があります +@msg the library has no group `{name}` ライブラリにグループ `{name}` はありません +@msg the library has no item `{item}` ライブラリに項目 `{item}` はありません +@msg the library has no item `{other}` ライブラリに項目 `{other}` はありません +@msg the library holds {MAX_GROUPS} groups already ライブラリにはすでに {MAX_GROUPS} 個のグループがあります +@msg the library holds {MAX_ITEMS} items of this kind already ライブラリにはこの種類の項目がすでに {MAX_ITEMS} 個あります +@msg the name is empty 名前が空です +@msg the object has no fill, stroke or effect オブジェクトに塗り、線、効果のいずれもありません +@msg the object has no graphic style (pass `name`) オブジェクトにグラフィックスタイルがありません (`name` を指定してください) +@msg the objects don't match the plan オブジェクトが計画と一致しません +@msg the open and permissions passwords must differ 文書を開くパスワードと権限パスワードには異なるものを指定してください +@msg the operand stack overflowed オペランドスタックがオーバーフローしました +@msg the page has no area ページに面積がありません +@msg the page's type in an unread object's box isn't in its page art 未読み込みオブジェクトのボックス内にあるページの文字が、ページのアートにありません +@msg the paint behind the proxy is not a gradient プロキシのペイントがグラデーションではありません +@msg the parent must be a layer or sublayer 親にはレイヤーまたはサブレイヤーを指定してください +@msg the path can't be extended パスを延長できません +@msg the path can't be inside one of the objects to attach パスを、配置するオブジェクトの中に置くことはできません +@msg the path has no segments to measure パスに計測できるセグメントがありません +@msg the path is empty パスが空です +@msg the path is too short to shorten that much パスが短すぎて、その長さだけ短くできません +@msg the picture has no objects 画像にオブジェクトがありません +@msg the program runs too long プログラムの実行時間が長すぎます +@msg the program uses too much memory プログラムのメモリ使用量が多すぎます +@msg the rectangle must have a width and a height 長方形には幅と高さが必要です +@msg the rectangle needs a size and must lie on the canvas 長方形にはサイズが必要で、キャンバス上にある必要があります +@msg the selected art has no width 選択したアートに幅がありません +@msg the selection draws nothing 選択範囲は何も描画しません +@msg the selection has no area 選択範囲に面積がありません +@msg the selection has no art to export 選択範囲に書き出せるアートがありません +@msg the selection has no bounds 選択範囲に境界がありません +@msg the selection has no graphic style 選択範囲にグラフィックスタイルがありません +@msg the slice would leave the canvas スライスがキャンバスからはみ出します +@msg the spot colour couldn't be added 特色を追加できませんでした +@msg the subpath has no ends to extend (it is closed or a single point) サブパスに延長できる端点がありません (閉じているか、点が 1 つだけです) +@msg the symbols are nested too deeply to expand their instances シンボルの入れ子が深すぎて、インスタンスを拡張できません +@msg the text file is too large to place as type (at most {_1} MB) テキストファイルが大きすぎて、文字として配置できません (最大 {_1} MB) +@msg the text is empty テキストが空です +@msg the text is over {_1} MB テキストが {_1} MB を超えています +@msg the top object must be a path 最前面のオブジェクトはパスである必要があります +@msg the trace was cancelled トレースがキャンセルされました +@msg the trailer has no size トレーラーにサイズがありません +@msg the type object is flattened to nothing 文字オブジェクトを分割・統合すると何も残りません +@msg the value of axis `{tag}` must be a number or null 軸 `{tag}` の値には数値または null を指定してください +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets 変数ライブラリで宣言されているデータセットが {MAX_LIBRARY_DATASETS} 個を超えています +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities 変数ライブラリで宣言されているエンティティが {MAX_LIBRARY_ENTITIES} 個を超えています +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables 変数ライブラリで宣言されている変数が {MAX_LIBRARY_VARIABLES} 個を超えています +@msg the variable library declares no variables 変数ライブラリで変数が宣言されていません +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes 変数ライブラリが {MAX_LIBRARY_BYTES} バイトを超えています +@msg the variable library is not UTF-8 変数ライブラリが UTF-8 ではありません +@msg the variable library is not well-formed XML: {e} 変数ライブラリが整形式の XML ではありません: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep 変数ライブラリの要素の入れ子が {MAX_LIBRARY_NESTING} 階層を超えています +@msg the variable library's document type declaration holds something other than entities of plain text 変数ライブラリの文書型宣言に、プレーンテキストのエンティティ以外のものが含まれています +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes 変数ライブラリのエンティティを展開すると {MAX_LIBRARY_BYTES} バイトを超えます +@msg the window has no dock ウィンドウにドックがありません +@msg the written PDF has no catalog 書き出した PDF にカタログがありません +@msg the written PDF has no cross-reference table 書き出した PDF に相互参照テーブルがありません +@msg the written PDF's metadata can't be edited 書き出した PDF のメタデータを編集できません +@msg there are no colour swatches to draw 描画するカラースウォッチがありません +@msg there is already a design named `{name}` `{name}` という名前のデザインがすでにあります +@msg there is already a design named `{to}` `{to}` という名前のデザインがすでにあります +@msg there is already a toolbar named `{name}` `{name}` という名前のツールバーがすでにあります +@msg there is already a toolbar named `{to}` `{to}` という名前のツールバーがすでにあります +@msg there is no library: create one first ライブラリがありません: 先に作成してください +@msg these paths are too degenerate for Shaper これらのパスは縮退しているため、Shaper では使用できません +@msg thumbnail: no page {v} (the {what} has {count}) thumbnail: ページ {v} はありません ({what} のページ数は {count}) +@msg tileRange of {what}: {e} {what} の tileRange: {e} +@msg title can't be empty title を空にすることはできません +@msg to (the new name) is required to (新しい名前) は必須です +@msg to must be fill or stroke to には fill または stroke を指定してください +@msg to must be smooth or corner to には smooth または corner を指定してください +@msg too large for a zip archive: package to a folder zip アーカイブには大きすぎます: フォルダーにパッケージしてください +@msg too many Shaper edits Shaper の編集が多すぎます +@msg too many `points` (at most 100000) `points` が多すぎます (最大 100000) +@msg too many characters: at most {MAX_CHARS} 文字数が多すぎます: 最大 {MAX_CHARS} 文字 +@msg too many dots for this size: lower the frequency このサイズにはドットが多すぎます: 線数を下げてください +@msg too many filters over one file 1 つのファイルにかかるフィルターが多すぎます +@msg too many overlapping paths for Shaper 重なり合うパスが Shaper には多すぎます +@msg too many saved clips 保存されたクリップが多すぎます +@msg too many saved graphics states 保存されたグラフィックス状態が多すぎます +@msg toolbar (a toolbar's name) must be a string toolbar (ツールバー名) には文字列を指定してください +@msg toolbar name can't be empty ツールバー名を空にすることはできません +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (初期設定) または "omit" を指定してください +@msg two saved selections would be named `{_1}` 保存した選択範囲のうち 2 つが `{_1}` という名前になります +@msg type dimension cannot be represented with this text transform このテキストの変形では文字の寸法を表現できません +@msg type dimension must be finite 文字の寸法には有限の値を指定してください +@msg unexpected resources 予期しないリソースがあります +@msg unknown CMYK profile `{name}` `{name}` は不明な CMYK プロファイルです +@msg unknown File Info field `{k}` `{k}` は不明なファイル情報フィールドです +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges 内の `{t}` は不明な OpenType 機能です (`features` で使用できるタグを指定してください) +@msg unknown PDF preset `{name}` (presets: {_1}) `{name}` は不明な PDF プリセットです (プリセット: {_1}) +@msg unknown Warp style 不明なワープスタイルです +@msg unknown alignToPath `{a}` `{a}` は不明な alignToPath の指定です +@msg unknown arrowhead {v} {v} は不明な矢印です +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) `{k}` は不明な artboardLayout のキーです (layout、columns、spacing、rightToLeft のいずれか) +@msg unknown attribute `{attribute}` `{attribute}` は不明な属性です +@msg unknown attribute `{k}` `{k}` は不明な属性です +@msg unknown axis `{other}` (vertical|horizontal|free) `{other}` は不明な軸の指定です (vertical|horizontal|free) +@msg unknown blend mode `{b}` `{b}` は不明な描画モードです +@msg unknown blend mode {b} {b} は不明な描画モードです +@msg unknown brush type `{o}` `{o}` は不明なブラシの種類です +@msg unknown category `{c}` `{c}` は不明なカテゴリです +@msg unknown category `{c}` ({_1}) `{c}` は不明なカテゴリです ({_1}) +@msg unknown character `{c}` `{c}` は不明な文字です +@msg unknown character style number field 文字スタイルの数値フィールドが不明です +@msg unknown effect `{e}` `{e}` は不明な効果です +@msg unknown end {e} {e} は不明な end の指定です +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) `{name}` は不明な flattenerPreset です (High Resolution、Medium Resolution、Low Resolution、または保存済みのプリセット: flattener.presets.list を参照) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) `{f}` は不明な形式です (vcswatches、gpl、ase、css のいずれか) +@msg unknown from {f} {f} は不明な from の指定です +@msg unknown graph type `{t}` `{t}` は不明なグラフの種類です +@msg unknown grid colours `{name}` (one of {_1}) `{name}` は不明なグリッドカラーです ({_1} のいずれか) +@msg unknown gridType `{t}` `{t}` は不明な gridType です +@msg unknown highlight `{v}` `{v}` は不明なハイライトの指定です +@msg unknown intent `{i}` `{i}` は不明なインテントです +@msg unknown issue kind {id} {id} は不明な問題の種類です +@msg unknown issue kind {v} {v} は不明な問題の種類です +@msg unknown knockout state {k} {k} は不明な抜きの状態です +@msg unknown language `{language}` (one of {_1}) `{language}` は不明な言語です ({_1} のいずれか) +@msg unknown language `{l}` (one of {_1}) `{l}` は不明な言語です ({_1} のいずれか) +@msg unknown method `{m}` ({_1}) `{m}` は不明な方法です ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) `{other}` は不明なモードです (gray、rgb、hsb、lab、cmyk、web のいずれか) +@msg unknown mode `{other}` (mono|cmyk) `{other}` は不明なモードです (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) `{other}` は不明なモードです (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) `{other}` は不明なモードです (set、add、subtract のいずれか) +@msg unknown mode `{o}` `{o}` は不明なモードです +@msg unknown mode {m} {m} は不明なモードです +@msg unknown model `{other}` `{other}` は不明な model です +@msg unknown model `{other}` (keep, new or document) `{other}` は不明な model です (keep、new、document のいずれか) +@msg unknown op {o} {o} は不明な op です +@msg unknown order `{o}`: toggle, front, forward, backward or back `{o}` は不明な order です: toggle、front、forward、backward、back のいずれかを指定してください +@msg unknown overprints `{v}` (preserve, simulate or discard) `{v}` は不明な overprints の指定です (preserve、simulate、discard のいずれか) +@msg unknown panel 不明なパネルです +@msg unknown pieLegend `{v}` (none, standard or wedges) `{v}` は不明な pieLegend の指定です (none、standard、wedges のいずれか) +@msg unknown piePosition `{v}` (even, ratio or stacked) `{v}` は不明な piePosition の指定です (even、ratio、stacked のいずれか) +@msg unknown pieSort `{v}` (none, all or first) `{v}` は不明な pieSort の指定です (none、all、first のいずれか) +@msg unknown plate `{n}` `{n}` は不明な版です +@msg unknown preference `{k}` `{k}` は不明な環境設定です +@msg unknown profile {id} (see stroke.widthProfile.list) {id} は不明なプロファイルです (stroke.widthProfile.list を参照) +@msg unknown proof target `{t}` `{t}` は不明な校正ターゲットです +@msg unknown quotes key `{k}` (double, single) `{k}` は不明な quotes のキーです (double、single のいずれか) +@msg unknown resolution `{v}` `{v}` は不明な解像度です +@msg unknown rule `{rule}` ({_1}) `{rule}` は不明なルールです ({_1}) +@msg unknown rule `{r}` (see color.harmony) `{r}` は不明なルールです (color.harmony を参照) +@msg unknown setting `{k}` `{k}` は不明な設定です +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) `{v}` は不明なシェイプです (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) `{o}` は不明な間隔の指定です (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) `{t}` は不明な tileType です ({_1}) +@msg unknown units 不明な単位です +@msg unknown units `{u}` `{u}` は不明な単位です +@msg unknown value axis `{v}` (left, right or both) `{v}` は不明な数値軸の指定です (left、right、both のいずれか) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) `{v}` は不明なバリエーションです (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` `{s}` は不明なワープスタイルです +@msg unknown {key} `{v}` (none, short or full) `{v}` は不明な {key} の指定です (none、short、full のいずれか) +@msg unknown {key} profile `{n}` `{n}` は不明な {key} プロファイルです +@msg unknown {k} key `{key}` (size, position) `{key}` は不明な {k} のキーです (size、position のいずれか) +@msg unreadable object 読み込めないオブジェクトです +@msg unsafe destination file name 出力先のファイル名が安全ではありません +@msg use paths or compound paths パスまたは複合パスを使用してください +@msg useArtboards must be true or false, not {v} useArtboards には true または false を指定してください ({v} は指定できません) +@msg value for `{var}` must match its variable kind `{var}` の値は変数の種類に合わせてください +@msg variable `{_1}` is already defined 変数 `{_1}` はすでに定義されています +@msg variable `{name}` has no `trait` 変数 `{name}` に `trait` がありません +@msg variable `{name}` is already defined 変数 `{name}` はすでに定義されています +@msg variable `{name}` is declared twice 変数 `{name}` が 2 回宣言されています +@msg variable `{to}` already exists 変数 `{to}` はすでにあります +@msg view is required view は必須です +@msg view must be one of {_1} view には {_1} のいずれかを指定してください +@msg warpStyle can only edit an applied Warp effect warpStyle で編集できるのは、適用済みのワープ効果だけです +@msg width and height must be more than 0 and at most {limit} width と height には 0 より大きく {limit} 以下の値を指定してください +@msg width factors must be finite 幅の係数には有限の値を指定してください +@msg width must be positive width には正の値を指定してください +@msg width out of range 幅が範囲外です +@msg width/height must be > 0 width/height には 0 より大きい値を指定してください +@msg x and y must be finite numbers x と y には有限の数値を指定してください +@msg x and y must be given together x と y は一緒に指定してください +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone には anchor が必要です: anchor でパネルの横にドッキングするか、side (Start または End) で新しいドック列にドッキングしてください +@msg zoom must be positive zoom には正の値を指定してください +@msg {C}: no effect at index {list} {C}: インデックス {list} に効果はありません +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} は保護されているため、削除されません +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} は保護されています: 設定を別の名前で保存してください +@msg {LABEL} produced an empty result {LABEL} の結果が空になりました +@msg {_1} MB of document, at most {_2} MB ドキュメントが {_1} MB あります (上限は {_2} MB) +@msg {_1} MB of images, at most {_2} MB 画像が {_1} MB あります (上限は {_2} MB) +@msg {_1} bytes of RGBA data for a {width} × {height} image {width} × {height} の画像に対して RGBA データが {_1} バイトあります +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} を読み込めないか、読み込み後に変更されています: 先に再リンクまたは更新してください +@msg {_1} can't go into {into} {_1} を {into} に入れることはできません +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} ではプリントを低解像度に制限できません: PDF 1.4 以降を選択するか、高解像度プリントを許可してください +@msg {_1} files can't be opened yet {_1} ファイルはまだ開けません +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1} ファイルはパスワードで保護できません: パスワードを削除するか、規格なしを選択してください +@msg {_1} files can't be {_2} {_1} ファイルは {_2} にできません +@msg {_1} files can't carry editing data: turn off preserveEditing {_1} ファイルには編集データを含められません: preserveEditing をオフにしてください +@msg {_1} files can't have PDF layers: turn off createLayers {_1} ファイルには PDF レイヤーを含められません: createLayers をオフにしてください +@msg {_1} files need a creation date, and this platform has no clock to give one {_1} ファイルには作成日が必要ですが、このプラットフォームには日時を取得できる時計がありません +@msg {_1} isn't a path {_1} はパスではありません +@msg {_1} options: {e} {_1} オプション: {e} +@msg {_1} paints nothing to rasterize {_1} にはラスタライズできる描画内容がありません +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1} のパスワードは ASCII 文字で {MAX_LEGACY_PASSWORD} 文字以内です (PDF 1.7 以降では任意の文字を使用できます) +@msg {_1} with linked images or an image map writes several files: use document.export リンク画像またはイメージマップのある {_1} は複数のファイルを書き出します: document.export を使用してください +@msg {_1} writes one file per artboard here: use document.export, or name one artboard ここでは {_1} はアートボードごとに 1 つのファイルを書き出します: document.export を使用するか、アートボードを 1 つ指定してください +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} ピクセルは {_3} には大きすぎます (1 辺あたり最大 {max} ピクセル): 拡大・縮小率を下げてください +@msg {_1}: belongs to another account {_1}: 別のアカウントのものです +@msg {_1}: not a folder of its own {_1}: 専用のフォルダーではありません +@msg {_1}: not a plain file {_1}: 通常のファイルではありません +@msg {dest}: {e} {dest}: {e} +@msg {doc} has no folder {doc} にフォルダーがありません +@msg {file} isn't linked {file} はリンクされていません +@msg {folder} is not a package folder {folder} はパッケージフォルダーではありません +@msg {folder}: more than {max} files and folders {folder}: ファイルとフォルダーが {max} 個を超えています +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} はライブペイントグループではありません +@msg {id} is not a slice {id} はスライスではありません +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} には 0–{MAX_ADD_AROUND} pt を指定してください +@msg {key} must be [x,y] {key} には [x,y] を指定してください +@msg {key} must be a percentage {min}..100 {key} には {min}..100 のパーセント値を指定してください +@msg {key} must be a positive number, not {n} {key} には正の数値を指定してください ({n} は指定できません) +@msg {key} must be a positive number, not {v} {key} には正の数値を指定してください ({v} は指定できません) +@msg {key} must be one of {_1} {key} には {_1} のいずれかを指定してください +@msg {key} must be {_1}, got {s} {key} には {_1} を指定してください (指定値: {s}) +@msg {key} must name a floating panel {key} にはフローティングパネルを指定してください +@msg {key} must name a panel in another group {key} には別のグループにあるパネルを指定してください +@msg {key} must name a panel that shows {key} には表示されているパネルを指定してください +@msg {key} must name a registered panel {key} には登録済みのパネルを指定してください +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} ファイルは透明に対応していませんが、ファイルに {what} が含まれます: 先に分割・統合してください (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind} 画像は開けません (document.formats を参照) +@msg {k} `{v}` is not one of the choices {k} `{v}` は選択肢にありません +@msg {k} has control characters{_1} {k} に制御文字{_1} が含まれています +@msg {k} is too long (at most {MAX_TEXT} characters) {k} が長すぎます (最大 {MAX_TEXT} 文字) +@msg {k} must be > 0 {k} には 0 より大きい値を指定してください +@msg {k} must be a number (pt) or a length such as "210 mm" {k} には数値 (pt)、または "210 mm" のような長さを指定してください +@msg {k} must be a number from {min} to {max} {k} には {min} から {max} までの数値を指定してください +@msg {k} must be a number or "auto" {k} には数値または "auto" を指定してください +@msg {k} must be a string {k} には文字列を指定してください +@msg {k} must be text {k} にはテキストを指定してください +@msg {k} must be true or false {k} には true または false を指定してください +@msg {k} must be {{size?, position?}} {k} には {{size?, position?}} を指定してください +@msg {k}: can't read `{s}` as a length {k}: `{s}` を長さとして読み取れません +@msg {label} must be a number at most {max} {label} には {max} 以下の数値を指定してください +@msg {label} must be a number from -1296 to 1296 {label} には -1296 から 1296 までの数値を指定してください +@msg {label} must be a number from 1 to 10000 {label} には 1 から 10000 までの数値を指定してください +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} は組み込みのため、削除できません +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} には {lo}–{hi}{unit} を指定してください (指定値: {v}) +@msg {name}: CRC or size mismatch {name}: CRC またはサイズが一致しません +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: この Affinity ファイルは埋め込みプレビューしか読み込めませんでした。「ファイル › 開く」で警告とともに表示するか、配置する前に Affinity から SVG または PDF を書き出してください +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}。埋め込みプレビューも読み込めません ({e}) +@msg {std} files are CMYK, and the file would have colours in {_1} {std} ファイルは CMYK ですが、ファイルに {_1} のカラーが含まれます +@msg {std} files embed their fonts, and the file would have one that isn't {std} ファイルにはフォントを埋め込みますが、ファイルに埋め込まれないフォントが含まれます +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} はこのドキュメントの読み込み元のファイルで、読み込み時に除外された内容があります ({what}): 上書きすると、それらは完全に失われます。別のファイルに書き出すか、それでも置き換える場合は acknowledgeLoss: true を指定してください +@msg {ty} brushes need selected art (or params.{k}) {ty} ブラシには選択したアート (または params.{k}) が必要です +@msg {v} is not an asset id {v} はアセット ID ではありません +@msg {what} can't be intertwined {what} は織り交ぜられません +@msg {what} doesn't fit the paper with its bleed and marks {what} は、裁ち落としとトンボを含めると用紙に収まりません +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} は {MAX_TILES} 枚を超えるタイルにプリントされます: 縮小してください Playback Options 再生オプション Accelerated 高速 Step by Step ステップ実行 diff --git a/docs/localization-parity.md b/docs/localization-parity.md index cd8dc33f30..12b68a0d8b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -34,7 +34,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 6,106 catalog entries (counted 2026-10-31 on `main` at `d26dd933a`): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | | Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | -| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | +| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all (3,459 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists no missing messages for it) | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | | Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7c1b28726..1d02d3c039 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -875,6 +875,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). + - The Japanese catalog (`crates/ui-egui/src/i18n/ja.tsv`) has an `@msg` row for every status and error message the untranslated report (`untranslated_report`) finds in the sources, 3,459 `@msg` rows in all. The report's Japanese section lists no messages. The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. From e06d0577b37e39d6f0e6ed7968aa0639db4f6228 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:00:22 -0800 Subject: [PATCH 03/89] i18n: Japanese leaves out the rows #7211, #7214 and #7116 add Open #7211, #7214 and #7116 add Japanese rows for 38 of the messages this branch translates. A second row for the same key fails bundled_catalogs_are_consistent once both sides merge, so this branch drops its 38 rows and leaves those messages to the other pull requests. The Japanese lines in docs/localization-parity.md and docs/target-app-parity.md give the new counts. --- crates/ui-egui/src/i18n/ja.tsv | 38 ---------------------------------- docs/localization-parity.md | 2 +- docs/target-app-parity.md | 2 +- 3 files changed, 2 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9b945bd23d..83145f57e2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6401,9 +6401,6 @@ axis Y Axis Y 軸 @msg `{KEY}` must be a list `{KEY}` にはリストを指定してください @msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` に指定できるエントリは最大 {MAX_PARAGRAPH_INDENTS} 個です (指定数: {_1}) @msg `{KEY}` takes no `{other}` beside it `{KEY}` と一緒に `{other}` は指定できません -@msg `{_1}` can't name a composite font `{_1}` は合成フォントの名前に使用できません -@msg `{_1}` is the name of an installed font `{_1}` はインストール済みのフォントの名前です -@msg `{_1}`: {_2} is set in `{font}`, a composite font `{_1}`: {_2} は合成フォント `{font}` で設定されています @msg `{base}` is larger than {_1} MB `{base}` は {_1} MB を超えています @msg `{base}` is not in the DCS file's folder `{base}` は DCS ファイルのフォルダーにありません @msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` の単語が {MAX_EDIT_WORDS} 個を超えています @@ -6422,20 +6419,13 @@ axis Y Axis Y 軸 @msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` にはパス上のオブジェクトグループのパスに配置されたオブジェクトを指定してください @msg `{key}` names an object that is hidden or locked `{key}` に指定されたオブジェクトは非表示またはロックされています @msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` は独立したファイルで、DCS ファイルのパスと同じ場所でのみ見つけられます -@msg `{name}`: give the kanji part `{name}`: 漢字の部分を指定してください -@msg `{name}`: parts must be an object `{name}`: parts にはオブジェクトを指定してください -@msg `{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals `{name}`: `{k}` は不明なクラスです。クラスは kanji、kana、punctuation、symbols、roman、numerals です -@msg `{name}`: unknown key `{k}` in {_1} `{name}`: {_1} の `{k}` は不明なキーです -@msg `{name}`: {_1} must be an object `{name}`: {_1} にはオブジェクトを指定してください @msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} -@msg `{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` は合成フォントの名前に使用できません (1~{COMPOSITE_NAME_MAX} 文字、制御文字なし) @msg `{u}` is no Unicode character `{u}` は Unicode 文字ではありません @msg `{word}` isn't in the custom dictionary `{word}` はカスタム辞書にありません @msg a CMap has too many mappings CMap のマッピングが多すぎます @msg a \\u escape needs four hexadecimal digits \\u エスケープには 4 桁の 16 進数が必要です @msg a comma in an XPath expression must separate function arguments XPath 式のカンマは関数の引数を区切るものでなければなりません @msg a document holds at most {_1} SVG filters ドキュメントに含められる SVG フィルターは最大 {_1} 個です -@msg a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts ドキュメントに保持できる合成フォントは最大 {COMPOSITE_FONTS_MAX} 個です @msg a document with unsaved changes has no recovery copy 未保存の変更があるドキュメントに復帰用のコピーがありません @msg a font's data is too large フォントのデータが大きすぎます @msg a font's subroutines are too large フォントのサブルーチンが大きすぎます @@ -6484,15 +6474,11 @@ axis Y Axis Y 軸 @msg character dimensions cannot be represented with the target text transform 文字の寸法をターゲットのテキスト変形で表すことができません @msg continuation must be an array of path chunks continuation にはパスチャンクの配列を指定してください @msg continuation requires points continuation には点が必要です -@msg dataBase64 is not base64 dataBase64 が base64 ではありません -@msg each font must be {name, parts} 各フォントは {name, parts} にしてください -@msg each font needs a name 各フォントに名前が必要です @msg each path chunk must contain 1 to 4096 points 各パスチャンクには 1~4096 個の点が必要です @msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) 各指定は {{at: [x, y]}} または {{rect: [x0, y0, x1, y1]}} です (指定値: {v}) @msg each weight must be a number of 0 or more (got {v}) 各ウェイトには 0 以上の数値を指定してください (指定値: {v}) @msg finally table {t} out of range finally テーブル {t} が範囲外です @msg flags cannot be given with a RegExp object RegExp オブジェクトと一緒にフラグを指定することはできません -@msg fonts must be a list of composite fonts fonts には合成フォントのリストを指定してください @msg function declaration index out of range 関数宣言のインデックスが範囲外です @msg give `char`, `text`, `code` or `unicode` `char`、`text`、`code`、`unicode` のいずれかを指定してください @msg give `count` or `spacing`, not both `count` または `spacing` のどちらか一方を指定してください @@ -6501,10 +6487,8 @@ axis Y Axis Y 軸 @msg give `xml`, one SVG element SVG の 要素を 1 つ `xml` に指定してください @msg give exactly one of `at`, `rect` and `points` `at`、`rect`、`points` のうち 1 つだけを指定してください @msg give exactly one of `path`, `data` and `dataBase64` `path`、`data`、`dataBase64` のうち 1 つだけを指定してください -@msg give fonts: [{name, parts}…] fonts: [{name, parts}…] を指定してください @msg give index or indexes, not both index または indexes のどちらか一方を指定してください @msg give one element 要素を 1 つ指定してください -@msg give one of path, data or dataBase64 path、data、dataBase64 のいずれかを指定してください @msg give opacity or blend opacity または blend を指定してください @msg handler at {_1} expects {_2} values but its range starts with {d} {_1} のハンドラーは {_2} 個の値を想定していますが、範囲の開始時は {d} 個です @msg handler depth too large ハンドラーの深さが大きすぎます @@ -6549,8 +6533,6 @@ axis Y Axis Y 軸 @msg must be an object with `kind` and `value` には `kind` と `value` を持つオブジェクトを指定してください @msg must have `kind` alert, confirm or prompt の `kind` には alert、confirm、prompt のいずれかを指定してください @msg must have exactly `kind` and `value` には `kind` と `value` だけを指定してください -@msg names must be a list of names names には名前のリストを指定してください -@msg names must be strings names には文字列を指定してください @msg needs a closing quote after the name 名前の後に閉じ引用符が必要です @msg needs a name 名前が必要です @msg needs a name of at most 64 bytes 64 バイト以内の名前が必要です @@ -6562,9 +6544,6 @@ axis Y Axis Y 軸 @msg no SVG filter at index {k} インデックス {k} に SVG フィルターはありません @msg no arrowheads file here ここに矢印ファイルはありません @msg no character has code {code} コード {code} の文字はありません -@msg no composite font 合成フォントがありません -@msg no composite font named `{n}` `{n}` という名前の合成フォントはありません -@msg no composite fonts to export 書き出す合成フォントがありません @msg no custom arrowhead {name} (see stroke.arrowheads.list) カスタム矢印 {name} はありません (stroke.arrowheads.list を参照) @msg no file to open 開くファイルがありません @msg no instructions 命令がありません @@ -6576,8 +6555,6 @@ axis Y Axis Y 軸 @msg node {_1} already has the text variable `{_2}` ノード {_1} にはすでにテキスト変数 `{_2}` があります @msg not a DCS file DCS ファイルではありません @msg not a RegExp object RegExp オブジェクトではありません -@msg not a composite fonts file: no compositeFonts list 合成フォントファイルではありません: compositeFonts リストがありません -@msg not a composite fonts file: {e} 合成フォントファイルではありません: {e} @msg not a regular expression 正規表現ではありません @msg not an action set: the text doesn't start with /version and a number アクションセットではありません: テキストが /version と数値で始まっていません @msg nothing to repeat 繰り返す対象がありません @@ -6592,7 +6569,6 @@ axis Y Axis Y 軸 @msg operand too large オペランドが大きすぎます @msg order is a whole number from 0 to {others}, not `{v}` order には 0 から {others} までの整数を指定してください (`{v}` は指定できません) @msg path chunks must share their boundary point パスの分割部分は境界点を共有している必要があります -@msg path is empty パスが空です @msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false の場合、useArtboards が false のときは、編集可能なアートワークを保持するためにアートボードが必要です @msg point x must be a number 点の x には数値を指定してください @msg point y must be a number 点の y には数値を指定してください @@ -6644,10 +6620,6 @@ axis Y Axis Y 軸 @msg the document has no SVG filter named {r} ドキュメントに {r} という名前の SVG フィルターはありません @msg the document has no color swatches ドキュメントにカラースウォッチがありません @msg the document's swatches hold {_1} colors; the document reduction takes at most 255 ドキュメントのスウォッチには {_1} 色ありますが、ドキュメント減色で使用できるのは最大 255 色です -@msg the file holds more than {COMPOSITE_FONTS_MAX} composite fonts ファイルに {COMPOSITE_FONTS_MAX} 個を超える合成フォントがあります -@msg the file holds no composite fonts ファイルに合成フォントがありません -@msg the file holds two composite fonts named `{_1}` ファイルに `{_1}` という名前の合成フォントが 2 つあります -@msg the file is larger than 4 MB ファイルが 4 MB を超えています @msg the file isn't valid SVG: {e} ファイルが有効な SVG ではありません: {e} @msg the file names no plate ファイルに版が指定されていません @msg the function's prototype is not an object 関数のプロトタイプがオブジェクトではありません @@ -6696,10 +6668,8 @@ axis Y Axis Y 軸 @msg toString is not a function toString は関数ではありません @msg too many arguments 引数が多すぎます @msg too many quantifiers 量指定子が多すぎます -@msg two composite fonts are named `{_1}` `{_1}` という名前の合成フォントが 2 つあります @msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) `{t}` は不明な OpenType 機能です (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt。オフにするには先頭に - を付けます。swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt には、`salt=2` のように `tag=N` の形式で代替字形の番号を指定できます。N は 0 から {_1} まで) @msg unknown dither `{d}` (diffusion, pattern, noise or none) `{d}` は不明なディザの指定です (diffusion、pattern、noise、none のいずれか) -@msg unknown key `{k}`; a font takes name and parts `{k}` は不明なキーです。フォントに指定できるのは name と parts です @msg unknown panel `{panel}` `{panel}` は不明なパネルです @msg unknown parameter `{key}` `{key}` は不明なパラメーターです @msg unknown parameter `{within}settings.{format}`; {why} `{within}settings.{format}` は不明なパラメーターです。{why} @@ -6720,13 +6690,7 @@ axis Y Axis Y 軸 @msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: カスタム矢印のパスには塗りつぶせる領域が必要で、アンカーは最大 {most} 個、サブパスは最大 {most} 個、各アンカーは先端から矢印単位で {far} 以内に収めてください @msg {k}: bad custom arrowhead path: {e} {k}: カスタム矢印のパスが正しくありません: {e} @msg {k}: bad custom arrowhead: {e} {k}: カスタム矢印が正しくありません: {e} -@msg {k}: baseline must be {_1} to {_2}% {k}: baseline には {_1}~{_2}% を指定してください @msg {k}: custom must be a name {k}: custom には名前を指定してください -@msg {k}: give a font {k}: フォントを指定してください -@msg {k}: only kana can be scaled at the center {k}: 中央を基準に拡大・縮小できるのはかなだけです -@msg {k}: only kana, roman and numerals can be scaled {k}: 拡大・縮小できるのはかな、欧文、数字だけです -@msg {k}: scale must be {_1} to {_2}% {k}: scale には {_1}~{_2}% を指定してください -@msg {k}: size must be {_1} to {_2}% {k}: size には {_1}~{_2}% を指定してください @msg {name} must be a number {name} には数値を指定してください @msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy で DCS ファイルを使用できるのは、版がファイル内にある場合だけです。版ファイルのある場所でそのファイルに再リンクしてください @msg {path}: larger than {_1} MB {path}: {_1} MB を超えています @@ -6996,7 +6960,6 @@ axis Y Axis Y 軸 @msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` は作業用スペースです: 先に edit.colorSettings で別のものを選択してください @msg `{name}` is built in and stays `{name}` は組み込みのため、削除されません @msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` には同じ種類のペイント (カラー、グラデーション、パターン) を指定してください -@msg `{name}`: Minimum must not exceed Desired, nor Desired Maximum `{name}`: 最小は最適を、最適は最大を超えないようにしてください @msg `{n}` can't be deleted `{n}` は削除できません @msg `{n}` can't be merged `{n}` は結合できません @msg `{n}` can't be moved `{n}` は移動できません @@ -7747,7 +7710,6 @@ axis Y Axis Y 軸 @msg select a graph グラフを選択してください @msg select a group or layer with visible, unlocked members 表示されていてロックされていないメンバーを含むグループまたはレイヤーを選択してください @msg select a layer レイヤーを選択してください -@msg select a path パスを選択してください @msg select a text object テキストオブジェクトを選択してください @msg select an image (or an Image Trace group) 画像 (または画像トレースグループ) を選択してください @msg select an image, or pass id 画像を選択するか、id を指定してください diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 12b68a0d8b..1df39336d0 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -34,7 +34,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 6,106 catalog entries (counted 2026-10-31 on `main` at `d26dd933a`): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | | Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | -| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all (3,459 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists no missing messages for it) | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | +| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all (3,421 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 38 missing messages for it) | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | | Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1d02d3c039..72ee4a28f4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -875,7 +875,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). - - The Japanese catalog (`crates/ui-egui/src/i18n/ja.tsv`) has an `@msg` row for every status and error message the untranslated report (`untranslated_report`) finds in the sources, 3,459 `@msg` rows in all. The report's Japanese section lists no messages. The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. + - The Japanese catalog (`crates/ui-egui/src/i18n/ja.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,421 `@msg` rows in all. The report lists 38 Japanese messages: the composite font, select-a-path and justification order errors that open #7211, #7214 and #7116 translate. The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. From 5b4e613d0b243c3fa10edb925eccd3378c27f61c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:03:37 -0800 Subject: [PATCH 04/89] 3D: colored-light facets close their shared edges over an underlay Under a colored light, Inflate's facets were antialiased one by one. Where two facets met, their edge pixels added up to less than full coverage. The solid-color composite then showed its black channel backdrop there, so internal edges drew darker at 1:1, and the per-facet gradient art let the background through. - The solid-color composite draws, in each channel, an underlay of the facets with 2 pt round seams between the black backdrop and the facets themselves. - The per-facet routes put each run of consecutive faces of one surface over an underlay of its own faces, clipped to the union of the faces and their seams. Runs that carry a gradient's opacity mask are sealed with that mask. A face clip's footprint keeps only the outer contours of its seam stroke. - The paint-copy allowance for cut faces counts 12 copies per facet. At 1:1, inside the surface each color channel of an opaque fill is within 3 levels of the same art drawn at 3x and averaged down, and every pixel is opaque. Before, 2,267 channels differed by up to 10 levels on the solid Inflate, and 1,088 pixels of the gradient Inflate were below full alpha. Under white light, the rendered pixels, the SVG text, the PDF read back and the expanded art of Inflate, Extrude, Rotate and Revolve are the same as before. Docs: docs/three-d.md (Seams), docs/target-app-parity.md. --- crates/effects/src/lighting.rs | 141 +++++++++++++---- crates/effects/src/solids.rs | 109 +++++++++++-- crates/effects/src/solids/tests.rs | 85 +++++++++- crates/engine/tests/it/three_d_light_color.rs | 145 ++++++++++++++++++ docs/target-app-parity.md | 2 +- docs/three-d.md | 7 +- 6 files changed, 448 insertions(+), 41 deletions(-) diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 8592776a95..378bfa5621 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -136,8 +136,86 @@ pub(crate) fn facet_art( composed_art(id, path, paint, Ramp::Rgb(light), seam) } -/// Composite smooth solid-fill facets with one clip and three channel groups per surface. -/// Black channel backdrops keep shared edges opaque before the final coverage is applied. +/// The width of the seam stroke of a facet's underlay: a round stroke that reaches 1 pt past each +/// edge, over the pixels that the antialiased edges of two neighboring facets each cover only in +/// part at 1:1. A clip to the surface's outline removes what it paints past the surface. +pub(crate) const UNDERLAY_SEAM: f64 = 2.0; + +/// The area facet `path` and its round seam stroke `seam` wide paint together: the stroke's outer +/// contours and the facet, all wound one way with a positive area. The stroke's inner contours, +/// wound the other way, are left out: the facet covers them, and contours that cancel each other +/// leave partial coverage where a union of footprints meets at a pixel. +pub(crate) fn footprint(path: &vectorcraft_geom::BezPath, seam: f64) -> vectorcraft_geom::BezPath { + use kurbo::{PathEl, Shape}; + let stroke = kurbo::stroke(path.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); + let sign = if stroke.area() < 0.0 { -1.0 } else { 1.0 }; + let mut footprint = vectorcraft_geom::BezPath::new(); + let mut contour = vectorcraft_geom::BezPath::new(); + for el in stroke.elements().iter().chain(std::iter::once(&PathEl::MoveTo(kurbo::Point::ZERO))) { + if matches!(el, PathEl::MoveTo(_)) && !contour.elements().is_empty() { + if contour.area() * sign >= 0.0 { + footprint.extend(contour.iter()); + } + contour = vectorcraft_geom::BezPath::new(); + } + contour.push(*el); + } + let mut boundary = path.clone(); + if boundary.area() * sign < 0.0 { + boundary = boundary.reverse_subpaths(); + } + footprint.extend(boundary.iter()); + if sign < 0.0 { footprint.reverse_subpaths() } else { footprint } +} + +/// Colored-light facets `faces` (in drawing order) drawn over an underlay of themselves, the same +/// facets grown to seams [`UNDERLAY_SEAM`] wide, all clipped once to the union of the facets' +/// footprints. Where two facets' antialiased edges meet, the underlay fills what they leave +/// uncovered with the neighbors' own colors, so the background does not show through. `None` unless +/// every face is a facet a surface draws: a path filled and stroked by its seam, or a facet composed +/// in a clip of its footprint, neither with an opacity mask. +pub(crate) fn sealed(id: vectorcraft_doc::NodeId, faces: &[std::sync::Arc]) -> Option { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeKind}; + let mut outline = vectorcraft_geom::BezPath::new(); + let mut underlay = Vec::with_capacity(faces.len()); + for face in faces { + if face.extras().mask.is_some() { + return None; + } + match &face.kind { + NodeKind::Path { clipping: false, .. } => { + let path = face.path_data()?.to_bezpath(); + let mut seam = None; + let mut grown = (**face).clone(); + for item in &mut grown.appearance.items { + if let AppearanceItem::Stroke(s) = item { + seam = Some(seam.map_or(s.width, |w: f64| w.max(s.width))); + s.width = UNDERLAY_SEAM; + } + } + outline.extend(footprint(&path, seam?).iter()); + underlay.push(Arc::new(grown)); + } + NodeKind::Group { children, clip: true } if children.len() == 2 => { + let clip = children.first()?.path_data()?.to_bezpath(); + let wider = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&clip, UNDERLAY_SEAM)), Appearance::default()); + let colors = children.get(1)?.clone(); + outline.extend(clip.iter()); + underlay.push(Arc::new(Node::new(id, NodeKind::Group { children: vec![Arc::new(wider), colors], clip: true }))); + } + _ => return None, + } + } + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&outline), Appearance::default()); + underlay.extend(faces.iter().cloned()); + Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, underlay))], clip: true })) +} + +/// Composite smooth solid-fill facets with one clip and three channel groups per surface. In each +/// channel, an underlay of the facets with wide seams lies under the facets themselves, so a +/// shared edge is opaque in that channel's own light before the channels are screened together; +/// the black backdrop shows only outside the surface, which the clip removes. pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node { use kurbo::Shape; use std::sync::Arc; @@ -167,20 +245,36 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t if let Some(value) = color.get_mut(channel) { *value = 1.0; } + let paints: Vec = facets + .iter() + .map(|face| { + if let Some(ramp) = face.rgb_gradient { + smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) + } else { + Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) + } + }) + .collect(); + let draw = |seam: fn(&vectorcraft_three_d::Facet) -> f64| -> Vec> { + facets + .iter() + .zip(&paints) + .map(|(face, paint)| { + let edge = seam_stroke(paint.clone(), seam(face)); + Arc::new(Node::path( + id, + face.path(), + Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(edge)], + ..Default::default() + }, + )) + }) + .collect() + }; let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))]; - children.extend(facets.iter().map(|face| { - let paint = if let Some(ramp) = face.rgb_gradient { - smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) - } else { - Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) - }; - let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - Arc::new(Node::path( - id, - face.path(), - Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() }, - )) - })); + children.extend(draw(|_| UNDERLAY_SEAM)); + children.extend(draw(|face| (face.thickness() / 2.0).clamp(0.05, 0.75))); let mut group = Node::group(id, children); group.isolate = true; group.blend = BlendMode::Screen; @@ -228,24 +322,19 @@ pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option) { } fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node { - use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask}; use vectorcraft_geom::{Rect, shapes}; let (base, mask) = opaque_paint(paint); // Clip once to the union of this single-contour face and its round seam stroke. - // Align their winding so the nonzero fill adds coverage instead of cancelling it. - let mut boundary = path.to_bezpath(); - let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); - if boundary.area() * footprint.area() < 0.0 { - boundary = boundary.reverse_subpaths(); - } - footprint.extend(boundary.iter()); - let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default()); + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&path.to_bezpath(), seam)), Appearance::default()); // Opaque rectangles cover the complete seam stroke, without separately antialiasing - // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. - let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0); + // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. They + // reach at least half a point past the seam stroke grown for an underlay ([`sealed`]), so + // their own edges stay outside either clip. + let margin = seam.max(1.0) + UNDERLAY_SEAM / 2.0; + let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(margin, margin); let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0)); let mut illumination = match light { Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)), diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index a68ff9529f..022dadfce9 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -599,11 +599,26 @@ fn paint_ref(item: &AppearanceItem) -> &Paint { } } -/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored -/// lights give each facet derived paints (`lighting::solid_surface`). +/// Are `facets` drawn over an underlay of themselves ([`crate::lighting::sealed`], +/// [`crate::lighting::solid_surface`])? Under colored lights, when any facet takes a smooth light +/// ramp. +fn underlaid(facets: &[Facet], colored: bool) -> bool { + colored && facets.iter().any(|f| f.rgb_gradient.is_some()) +} + +/// The most paint copies `surface` makes for one facet ([`facet_copies`]). +const MAX_FACET_COPIES: usize = 12; + +/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), twice that when +/// the facets lie over an underlay of themselves, and [`MAX_FACET_COPIES`] when colored lights also +/// give each facet derived paints in three channels (`lighting::solid_surface`). fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { - let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some()); - facets.len().saturating_mul(if derived { 6 } else { 2 }) + let per_facet = match (underlaid(facets, colored), paint.color().is_some()) { + (true, true) => MAX_FACET_COPIES, + (true, false) => 4, + (false, _) => 2, + }; + facets.len().saturating_mul(per_facet) } /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the @@ -789,14 +804,14 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { /// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces /// and whose paint copies take `paint_bytes` so far: as many as keep the faces within -/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies -/// of the costliest stroke's paint. +/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as +/// [`MAX_FACET_COPIES`] copies of the costliest stroke's paint. fn cut_room<'a>(items: impl Iterator, faces: usize, paint_bytes: usize) -> usize { let per_face = items .filter(|item| matches!(item, AppearanceItem::Stroke(_))) .map(|item| { let mut copies = 0usize; - crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item)); + crate::revolve::paint_copies_add(&mut copies, MAX_FACET_COPIES, paint_ref(item)); copies }) .max() @@ -1606,6 +1621,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) }; let members: Vec = solids.iter().map(|s| s.4).collect(); + // Which surfaces lie over an underlay of their facets, in one order or whole + // ([`in_one_order`], [`underlaid_surface`]). + let seals: Vec = solids.iter().map(|(.., f, _)| underlaid(f, colored)).collect(); + let seal = |range: std::ops::Range| seals.get(range).is_some_and(|s| s.iter().any(|s| *s)); let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { @@ -1637,19 +1656,37 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile run.push(s); } let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none()); + let range = first..first + run.len(); if ordered(member) && flat { let scopes = run.first().map_or(&[][..], |s| s.2); - out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed))))); + let seals = seals.get(range).unwrap_or_default(); + out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed, seals))))); } else { - out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); + let run = run.into_iter().zip(range).map(|((_, s, scopes, _), k)| (scopes, Arc::new(underlaid_surface(n.id, s, seal(k..k + 1))))); + out.extend(with_graphics(run.collect(), first, &arts)); } } return Some(masked(n.id, out)); } if !opaque || !surfaces.iter().all(plain) { + let surfaces = + surfaces.into_iter().enumerate().map(|(k, (d, s, scopes, e))| (d, underlaid_surface(n.id, s, seal(k..k + 1)), scopes, e)).collect(); return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); } - Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) + Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed, &seals)))]) +} + +/// A plain `surface` ([`surface`]: a group of its facets) over an underlay of its facets when +/// `seal`, keeping the surface's opacity, blend mode and opacity mask. +fn underlaid_surface(id: NodeId, surface: Node, seal: bool) -> Node { + let NodeKind::Group { children, clip: false } = &surface.kind else { return surface }; + let Some(mut out) = seal.then(|| crate::lighting::sealed(id, children)).flatten() else { return surface }; + out.opacity = surface.opacity; + out.blend = surface.blend; + if let Some(mask) = surface.extras().mask.clone() { + out.extras_mut().mask = Some(mask); + } + out } /// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in @@ -1657,7 +1694,16 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile /// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops /// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of /// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. -fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { +/// The faces of the surfaces that `seals` marks lie over underlays of themselves +/// ([`underlaid_runs`], [`underlaid_surface`]). +fn in_one_order( + id: NodeId, + surfaces: Vec>, + first: usize, + arts: &[(usize, PlanarFace, Arc)], + sealed: bool, + seals: &[bool], +) -> Vec> { let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); @@ -1696,7 +1742,46 @@ fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: (masks.len(), art) })); } - if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() } + let takes_underlay = |surface: usize| seals.get(surface) == Some(&true); + if masks.iter().any(Option::is_some) { + // The faces of a surface without a mask are sealed run by run before the masked surfaces' + // faces go in runs of their own, each holding one surface's faces, sealed with its mask. + let unmasked = underlaid_runs(id, drawn, |s| takes_underlay(s) && masks.get(s).is_some_and(Option::is_none)); + return in_runs(id, unmasked, &masks) + .into_iter() + .map(|run| match &run.kind { + NodeKind::Group { clip: false, .. } if seals.iter().any(|s| *s) && run.extras().mask.is_some() => { + Arc::new(underlaid_surface(id, (*run).clone(), true)) + } + _ => run, + }) + .collect(); + } + underlaid_runs(id, drawn, takes_underlay).into_iter().map(|(_, face)| face).collect() +} + +/// `faces` in their drawing order, each with the index of its surface. Each run of consecutive +/// faces of one surface that `seal` takes lies over an underlay of its own faces, clipped to their +/// outline ([`crate::lighting::sealed`]), so no underlay reaches under another surface's faces or a +/// graphic drawn between them. A sealed run keeps its surface's index. +fn underlaid_runs(id: NodeId, faces: Vec<(usize, Arc)>, seal: impl Fn(usize) -> bool) -> Vec<(usize, Arc)> { + let mut out = Vec::with_capacity(faces.len()); + let mut run: Vec> = vec![]; + let mut faces = faces.into_iter().peekable(); + while let Some((surface, face)) = faces.next() { + run.push(face); + if faces.peek().is_some_and(|(next, _)| *next == surface) { + continue; + } + match seal(surface).then(|| crate::lighting::sealed(id, &run)).flatten() { + Some(sealed) => { + out.push((surface, Arc::new(sealed))); + run.clear(); + } + None => out.extend(run.drain(..).map(|face| (surface, face))), + } + } + out } /// `faces` in their back-to-front order, each with the index of its surface, where some surfaces diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index c825159dda..ccf87e37a0 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -535,6 +535,89 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { assert_eq!(drawn(&solid, Some(colored)).0, 2); } +/// Under a colored light, the faces of an Inflate in gradients, in one order, lie over underlays +/// run by run: each run of consecutive faces of one surface (the fill's or the dashed stroke's) +/// over an underlay of its own faces, so no underlay reaches under the other surface's faces. +#[test] +fn a_colored_inflate_in_gradients_lies_over_underlays_of_each_surfaces_runs() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |c: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)], + )))) + }; + let mut n = stroked(&letter_c()); + for item in &mut n.appearance.items { + match item { + AppearanceItem::Fill(f) => f.paint = gradient(RED), + AppearanceItem::Stroke(s) => { + s.paint = gradient(BLUE); + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + } + } + let p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}); + let cut = drawn_parts(&n, INFLATE, &p).len(); + let mut lit = p.clone(); + lit["lights"] = json!([{"color": "#ff8080"}]); + n.appearance.effects.push(fx(INFLATE, lit)); + // Which surface a face belongs to, by the first stop of its paint (a composed face's own paint, + // or a path face's shaded one): red for the fill's, blue for the stroke's. + fn surface_of(face: &Node) -> Option { + let item = match &face.kind { + NodeKind::Path { .. } => face.appearance.items.first()?, + _ => face.children()?.get(1)?.children()?.first()?.appearance.items.first()?, + }; + let Paint::Gradient(g) = item.paint() else { return None }; + let [r, _, b] = g.gradient.stops.first()?.color.to_rgb(); + Some(if r > b { RED } else { BLUE }) + } + // The faces in `order`, and the surface of each run over an underlay, looking into the groups + // that carry a surface's opacity mask. + fn runs(order: &[Arc], faces: &mut usize, surfaces: &mut Vec) { + for child in order { + let NodeKind::Group { children, clip } = &child.kind else { + assert!(surface_of(child).is_some(), "a face"); + *faces += 1; + continue; + }; + if !clip { + assert!(child.extras().mask.is_some(), "a masked run"); + runs(children, faces, surfaces); + continue; + } + // A composed face isolates its colors; a run's faces are not isolated together. + if children[1].isolate { + assert!(surface_of(child).is_some(), "a composed face"); + *faces += 1; + continue; + } + let stack = children[1].children().unwrap(); + let (under, own) = stack.split_at(stack.len() / 2); + assert_eq!(under.len(), own.len(), "one underlay face for each face"); + let colors: Vec<_> = own.iter().map(|f| surface_of(f).unwrap()).collect(); + assert!(colors.iter().all(|c| *c == colors[0]), "a run holds one surface's faces"); + surfaces.push(colors[0]); + *faces += own.len(); + } + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut, "{faces} faces, {cut} under a white light, whose cells are not triangulated"); + assert!(surfaces.contains(&RED) && surfaces.contains(&BLUE), "{surfaces:?}"); + // With transparent stops in the fill's gradient, the fill's faces go in masked runs; the + // stroke's faces, without a mask, still lie over underlays of their own runs. + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) + && let Paint::Gradient(g) = &mut f.paint + { + g.gradient.stops.iter_mut().for_each(|s| s.opacity = 0.5); + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut && surfaces.contains(&RED) && surfaces.contains(&BLUE), "{faces} faces, {surfaces:?}"); +} + /// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they /// overlap is cut along the line where their depths meet, into the piece in front of the wall and /// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke @@ -622,7 +705,7 @@ fn cutting_stays_within_the_face_limit_and_the_paint_allowance() { let items = stroked(&letter_c()).appearance.items.clone(); let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap(); let mut one = 0usize; - crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke)); + crate::revolve::paint_copies_add(&mut one, MAX_FACET_COPIES, paint_ref(stroke)); let max = crate::revolve::MAX_PAINT_COPIES; assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one)); assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0); diff --git a/crates/engine/tests/it/three_d_light_color.rs b/crates/engine/tests/it/three_d_light_color.rs index 91ce875796..8723e8d0a3 100644 --- a/crates/engine/tests/it/three_d_light_color.rs +++ b/crates/engine/tests/it/three_d_light_color.rs @@ -481,6 +481,151 @@ fn solid_gradient_opacity_is_applied_once_even_without_shading() { } } +/// How `image`, `d` drawn at 1:1 on 400 × 400 pixels, differs from `d` drawn at 3x and averaged +/// down, on the pixels inside the art: those whose 3x average has some alpha, the same as every +/// pixel within 3 of them. Left out too are pixels where the 3x average bends sharply (a channel's +/// second difference across the pixel, along x or y, over 4 levels), such as the rim where a cap +/// meets a body: a gradient sampled at each pixel's center matches an area average only where the +/// light changes close to linearly across the pixel. Returns (pixels measured, premultiplied RGB +/// channels more than 3 levels off, the largest RGB difference, alphas off: below 255 where the 3x +/// average is opaque, otherwise more than 3 levels off, the first few pixels off). +fn seams_against_supersampled(d: &Document, image: &vectorcraft_render::Rendered) -> (usize, usize, u8, usize, Vec) { + let fine = vectorcraft_render::Renderer::new().render( + d, + 1200, + 1200, + kurbo::Affine::scale(3.0), + &vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() }, + ); + let average = fine.area_resized(400, 400).unwrap(); + let (mut measured, mut color, mut max, mut holes) = (0, 0, 0, 0); + let mut worst = vec![]; + for y in 3..397 { + for x in 3..397 { + let q = average.pixel(x, y); + if q[3] == 0 || !(y - 3..=y + 3).all(|y| (x - 3..=x + 3).all(|x| average.pixel(x, y)[3] == q[3])) { + continue; + } + let bend = |a: [u8; 4], b: [u8; 4]| (0..3).any(|c| (i32::from(a[c]) + i32::from(b[c]) - 2 * i32::from(q[c])).abs() > 4); + if bend(average.pixel(x - 1, y), average.pixel(x + 1, y)) || bend(average.pixel(x, y - 1), average.pixel(x, y + 1)) { + continue; + } + measured += 1; + let p = image.pixel(x, y); + for c in 0..3 { + let diff = p[c].abs_diff(q[c]); + max = max.max(diff); + color += usize::from(diff > 3); + } + let hole = if q[3] == 255 { p[3] < 255 } else { p[3].abs_diff(q[3]) > 3 }; + holes += usize::from(hole); + if (p.into_iter().zip(q).any(|(a, b)| a.abs_diff(b) > 3) || hole) && worst.len() < 4 { + worst.push(format!("({x},{y}) {p:?} vs {q:?}")); + } + } + } + (measured, color, max, holes, worst) +} + +/// Colored lights on smooth 3D surfaces, compared at 1:1 with a 3x render averaged down, on every +/// pixel inside the art ([`seams_against_supersampled`]). A solid fill draws as one batched surface; +/// a source gradient composes each facet on its own, and one with transparent stops applies their +/// opacity once over the surface. Inflate seen straight on, one layer of facets, has no alpha off, and +/// in opaque fills no RGB channel more than 3 levels off. Inflate turned so facets narrow to slivers +/// at the edges, and Revolve with a cap over its body, draw facets over other facets, and have no +/// alpha off. +#[test] +fn colored_light_surfaces_match_supersampling_at_internal_facet_edges() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + let fills = |start: [u32; 2], end: [u32; 2]| { + [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6} + ]}}), + ] + }; + let mut cases = vec![]; + for rotation in [(0, 0), (50, 35)] { + let (mut s, _) = setup("threeD.inflate"); + let change = json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights,"rotationX":rotation.0,"rotationY":rotation.1}}); + s.execute("effect.setParams", &change).unwrap(); + cases.push((format!("inflate {rotation:?}"), s, fills([100, 150], [200, 150]), rotation == (0, 0))); + } + let (mut s, _) = capped_profile(); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + cases.push(("revolve".into(), s, fills([80, 200], [240, 200]), false)); + let mut failures = vec![]; + for (name, mut s, fills, one_layer) in cases { + for fill in fills { + s.execute("paint.setFill", &fill).unwrap(); + let image = render(doc(&s)); + let (measured, color, max, holes, worst) = seams_against_supersampled(doc(&s), &image); + assert!(measured > 2_000, "{name} {fill}: {measured} pixels inside"); + let opaque = !fill.to_string().contains("opacity"); + if holes > 0 || (one_layer && opaque && color > 0) { + failures.push(format!( + "{name} {fill}: of {measured} pixels, {color} channels off by more than 3 (at most {max}), {holes} alphas off: {worst:?}" + )); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// The closed facet edges of colored-light surfaces survive SVG and PDF, read back and drawn at +/// 1:1, and Expand Appearance: inside the art, every pixel stays opaque and within 6 levels of the +/// live art. +#[test] +fn colored_light_seams_stay_closed_in_svg_pdf_and_expansion() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + for fill in [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":[100,150],"end":[200,150],"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + ] { + let (mut s, _) = setup("threeD.inflate"); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + let live = render(doc(&s)); + let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned(); + assert!(svg.contains(" Date: Fri, 13 Nov 2026 16:20:17 -0800 Subject: [PATCH 05/89] Docs: Japanese message coverage is partial, with 38 rows left to open pull requests The Japanese row of docs/localization-parity.md marks its messages as partial and names the 38 messages the untranslated report lists without a row: the 36 composite font errors that open #7211 translates, "select a path" (open #7214) and the justification order error (open #7116). It also names the three rows that do not match their message yet and why. The Japanese line in docs/target-app-parity.md says the catalog has a row for each reported message except those 38. --- docs/localization-parity.md | 2 +- docs/target-app-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 1df39336d0..ff5c7c7cb7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -34,7 +34,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 6,106 catalog entries (counted 2026-10-31 on `main` at `d26dd933a`): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | | Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | -| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all (3,421 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 38 missing messages for it) | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | +| Japanese | `ja` | 4,004 / 4,020 (99.6%) | partial: 3,421 `@msg` and 48 `@msgplural` rows (counted 2026-11-13). The untranslated report lists 38 messages without a row: the 36 composite font errors that open #7211 translates, `select a path` (open #7214) and ``{name}`: Minimum must not exceed Desired, nor Desired Maximum`` (open #7116). Three rows do not match their message yet: `Template::parse` returns no template for a message with two placeholders in a row, so the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})` are not used, and the row for `{k} has control characters{_1}` does not match the multi-line File Info error, whose last placeholder is empty | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | | Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 72ee4a28f4..d850450b6e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -875,7 +875,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). - - The Japanese catalog (`crates/ui-egui/src/i18n/ja.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,421 `@msg` rows in all. The report lists 38 Japanese messages: the composite font, select-a-path and justification order errors that open #7211, #7214 and #7116 translate. The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. + - The Japanese catalog (`crates/ui-egui/src/i18n/ja.tsv`) has 3,421 `@msg` rows: a row for each status and error message the untranslated report (`untranslated_report`) finds in the sources, except 38. The report lists those 38 for Japanese: the 36 composite font errors that open #7211 translates, `select a path` (open #7214) and the justification order error (open #7116). The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. From 0cb2b435a80e16a96f1460a4763d3b77c18e78c1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:23:32 -0800 Subject: [PATCH 06/89] Live Paint: a Live Paint Selection double-click selects the connected faces or edges A double-click with the Live Paint Selection tool on a face now selects that face and the faces of its group that no stroked edge parts from it: the selection floods across edges that are hidden or draw no stroke, and hidden faces are left out. On an edge (Alt-double-click, or a double-click with Select Fills off) it selects the edge and the edges of its group joined to it end to end that draw with the same stroke paint. With Shift held, the faces or edges reached leave the selection or join it. The tool options, locks and isolation mode apply as they do to a click. The reach is computed by two read-only functions in the builder lookup module, flood_faces and connected_edges, so the Live Paint commands can use the same rules. An edge's stroke paint is the paint of its topmost stroke that draws. Face outlines are matched to edges within the planar map's precision, and edges join only at the ends of their subpaths. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/tests/build.rs | 31 ++ crates/tools/src/builder.rs | 6 +- crates/tools/src/builder/live_paint.rs | 33 +- crates/tools/src/builder/lookup.rs | 343 +++++++++++++++ crates/tools/src/builder/tests.rs | 577 +++++++++++++++++++++++++ docs/mcp.md | 10 + docs/target-app-parity.md | 1 + 7 files changed, 995 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index cb61ac1b9e..0791ce538d 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3307,6 +3307,37 @@ fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() { assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_))); } +#[test] +fn live_paint_selection_double_click_selects_the_faces_no_stroked_edge_parts() { + let mut s = session(); + let g = live(&mut s); + // The edge between the middle and right faces draws no stroke. + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100.0, 50.0], "none": true})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("livePaintSelection", v).unwrap(); + let gn = node(&s, g); + let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id; + let before = s.doc().unwrap().doc.clone(); + let double_click = |s: &mut Session, x: f64, m: Mods| { + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + s.pointer(&PointerEvent::new(kind, x, 50.0).with_mods(m), v).unwrap(); + } + let mut ids = s.doc().unwrap().selection.objects.clone(); + ids.sort(); + ids + }; + let sorted = |mut ids: Vec| { + ids.sort(); + ids + }; + assert_eq!(double_click(&mut s, 125.0, Mods::default()), sorted(vec![face(75.0), face(125.0)])); + // The stroked edge at x = 50 parts the left face from the others. + assert_eq!(double_click(&mut s, 25.0, Mods::default()), vec![face(25.0)]); + // Shift-double-click adds the faces reached to the selection. + assert_eq!(double_click(&mut s, 75.0, Mods { shift: true, ..Default::default() }), sorted(vec![face(25.0), face(75.0), face(125.0)])); + assert_eq!(s.doc().unwrap().doc, before, "selecting changes no paint"); +} + #[test] fn live_paint_selection_marquee_selects_what_it_touches() { let mut s = session(); diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 2190d34180..0a6cf583cf 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -41,9 +41,9 @@ pub use live_paint::{ }; use live_paint::{check_live_paint_options, lp_color, lp_rgb_color}; pub use lookup::{ - EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, - is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, - sources, visible_edge_near, visible_face_at, + EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, LP_JOIN_TOL, SOURCES_NAME, builder_members, connected_edges, drawn_outlines, + edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, + path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at, }; pub use map::{ BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs, diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 6b744e4435..15b5c55553 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,5 +1,7 @@ use super::cache::RegionCache; -use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at}; +use super::lookup::{ + connected_edges, flood_faces, is_live_paint, live_paint_at, live_paint_at_where, lp_stroke_paint, visible_edge_near, visible_face_at, +}; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent, @@ -471,7 +473,11 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); /// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. A drag draws /// a marquee, and on release the faces and edges it touches ([`lp_parts_in`]) become the selection; /// with Shift held at the press, each of them leaves the selection if it is selected and joins it -/// otherwise. A click and a marquee change the selection on release, so Escape between the press +/// otherwise. A double-click on a face selects it and the faces of its group that are not parted +/// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end +/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them +/// leaves the selection if it is selected and joins it otherwise. +/// A click and a marquee change the selection on release, so Escape between the press /// and the release (which removes the marquee) leaves the selection as it was. The tool does not /// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer /// or on a locked artboard, or in isolation mode outside the isolated art. Its Tool Options @@ -558,6 +564,18 @@ impl LivePaintSelectionTool { }; (hit, cursor) } + + /// What a double-click at `p` selects, Alt held or not: on the face a click there picks + /// ([`LivePaintSelectionTool::picks_edge`]), that face and the faces of its group a flood from it + /// reaches across edges that are hidden or draw no stroke ([`flood_faces`]); on the edge a click + /// picks, that edge and the edges of its group joined to it end to end through edges that draw + /// with the stroke paint it draws with ([`connected_edges`], [`lp_stroke_paint`]). Empty where a click selects no face or edge. + fn reach_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec { + let Some(edge) = self.picks_edge(alt) else { return vec![] }; + let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] }; + let Some(group) = cx.doc.node(gid) else { return vec![] }; + if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) } + } } /// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and @@ -679,7 +697,16 @@ impl Tool for LivePaintSelectionTool { (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } - _ => vec![], + PointerKind::DoubleClick => { + // The two clicks before it already selected the part under the pointer (or, with + // Shift, toggled it twice); the double-click selects the part with what it reaches. + let ids = self.reach_at(cx, p, ev.mods.alt); + match (ids.is_empty(), ev.mods.shift) { + (true, _) => vec![], + (false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))], + (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], + } + } } } diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 5e85d9339d..f577732ade 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -104,6 +104,349 @@ fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, to best.map(|b| b.1) } +/// The greatest distance (pt) between the ends of two Live Paint edges that the Live Paint tools take +/// as one point. Every edge comes from one planar map, so their ends meet this closely. +pub const LP_JOIN_TOL: f64 = 1e-4; + +/// The greatest distance (pt) between the middle of a Live Paint face's outline segment and an edge +/// it runs along. The planar map refits outlines to [`vectorcraft_pathops::DEFAULT_PRECISION`], +/// keeping each refit segment's ends, and with Gap Detection on, the faces and the edges come from +/// two maps, so an outline can lie that far off its edges between its ends. +const LP_FACE_EDGE_TOL: f64 = vectorcraft_pathops::DEFAULT_PRECISION; + +/// The cell [`LP_JOIN_TOL`] wide that holds `p`, for finding the points near it; two points within +/// [`LP_JOIN_TOL`] of each other lie in the same or neighboring cells. The cast saturates, so a +/// coordinate too large or not finite does not overflow. +fn lp_cell(p: Point) -> (i64, i64) { + ((p.x / LP_JOIN_TOL).round() as i64, (p.y / LP_JOIN_TOL).round() as i64) +} + +/// The cell that holds `p` ([`lp_cell`]) and the eight cells around it. +fn lp_cells_around(p: Point) -> impl Iterator { + let (x, y) = lp_cell(p); + (-1..=1).flat_map(move |dx: i64| (-1..=1).map(move |dy: i64| (x.saturating_add(dx), y.saturating_add(dy)))) +} + +/// The stroke paint Live Paint edge `edge` draws with: the paint of its topmost stroke that is shown +/// with a paint, a weight above 0 and an opacity above 0, or [`Paint::None`] when the edge is fully +/// transparent or has no such stroke. +pub(super) fn lp_stroke_paint(edge: &Node) -> Paint { + if edge.opacity <= 0.0 { + return Paint::None; + } + edge.appearance + .items + .iter() + .rev() + .find_map(|i| match i { + vectorcraft_doc::appearance::AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0 && s.opacity > 0.0 => { + Some(s.paint.clone()) + } + _ => None, + }) + .unwrap_or(Paint::None) +} + +/// Whether Live Paint edge `edge` draws no stroke ([`lp_stroke_paint`] is [`Paint::None`]). +fn unstroked(edge: &Node) -> bool { + lp_stroke_paint(edge).is_none() +} + +/// The segments of `pd`. +fn lp_segments(pd: &PathData) -> Vec { + pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect() +} + +/// The faces of Live Paint group `group` that a flood from face `start` reaches, `start` first: +/// `start`, and each face that shares with a face already reached an edge that is hidden or draws +/// no stroke. Hidden faces are not reached and do not pass the flood on. A face runs along the parts +/// of the edge segments its outline segments lie on ([`lp_lies_on`]) or overlap ([`lp_overlap`]). +/// Two faces share an edge where those parts overlap along more than [`LP_FACE_EDGE_TOL`], or, where +/// both outlines lie within [`LP_JOIN_TOL`] of the edge, along more than half the shorter part. So +/// two faces that a Gap Detection gap parts on the same side of an edge, whose parts only touch, do +/// not share it, and two faces whose shared edge is shorter than [`LP_FACE_EDGE_TOL`] do. Empty when +/// `start` is not a face of `group` that is not hidden. +pub fn flood_faces(group: &Node, start: NodeId) -> Vec { + use kurbo::{ParamCurve, ParamCurveArclen}; + use std::collections::{HashMap, HashSet}; + let shown: Vec<&Arc> = faces(group).into_iter().filter(|f| f.visible).collect(); + if !shown.iter().any(|f| f.id == start) { + return vec![]; + } + // Each edge segment, and whether a flood crosses it. + let edge_segs: Vec<(kurbo::CubicBez, bool)> = edges(group) + .into_iter() + .flat_map(|e| { + let cross = !e.visible || unstroked(e); + e.path_data().map(lp_segments).unwrap_or_default().into_iter().map(move |s| (s, cross)) + }) + .collect(); + // The faces along each edge segment a flood crosses, each with the part of the segment it runs + // along (from and to, in the segment's own parameter). A face outline segment usually lies on + // one edge segment, stroked or not ([`lp_lies_on`]); one that does not (a piece of an edge + // segment where a Gap Detection line meets it, the line itself, or an outline segment that spans + // several edge segments) is measured against the edge segments a flood crosses that its bounds + // meet ([`lp_overlap`]). Each part also records whether its outline segment lies within + // [`LP_JOIN_TOL`] of it ([`lp_close`]). + let all: Vec<(kurbo::CubicBez, bool)> = edge_segs.iter().map(|&(s, _)| (s, true)).collect(); + let all_grid = LpEdgeGrid::new(&all); + let mut along: HashMap> = HashMap::new(); + let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![]; + for f in &shown { + for piece in f.path_data().map(lp_segments).unwrap_or_default() { + let m = piece.eval(0.5); + let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL); + // The edge segment the outline segment lies on whose middle is nearest its own. + let on = all_grid + .near_box(bounds) + .into_iter() + .filter_map(|k| edge_segs.get(k).and_then(|(s, cross)| lp_lies_on(s, &piece).map(|part| (k, s, *cross, part)))) + .min_by(|x, y| (x.1.eval(0.5) - m).hypot().total_cmp(&(y.1.eval(0.5) - m).hypot())); + match on { + Some((k, s, cross, (from, to))) => { + if cross { + along.entry(k).or_default().push((f.id, from, to, lp_close(s, from, to, &piece))); + } + } + None => pieces.push((piece, f.id)), + } + } + } + if !pieces.is_empty() { + let grid = LpEdgeGrid::new(&edge_segs); + for (piece, id) in pieces { + let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL); + for k in grid.near_box(bounds) { + if let Some((s, true)) = edge_segs.get(k) + && let Some((a, c)) = lp_overlap(s, &piece) + { + along.entry(k).or_default().push((id, a, c, lp_close(s, a, c, &piece))); + } + } + } + } + let mut next: HashMap> = HashMap::new(); + // Sorted by where each part starts, a part is compared only with the parts after it that start + // before it ends. Two parts that overlap along [`LP_FACE_EDGE_TOL`] or less join only when both + // lie within [`LP_JOIN_TOL`] of the edge and the overlap is more than half the shorter part: an + // edge that short is shared, while an outline that only crosses it lies off it. + for (k, on) in &mut along { + let Some((s, _)) = edge_segs.get(*k) else { continue }; + on.sort_by(|x, y| x.1.total_cmp(&y.1)); + for (i, &(a, a0, a1, a_close)) in on.iter().enumerate() { + for &(c, c0, c1, c_close) in on.iter().skip(i + 1).take_while(|x| x.1 < a1) { + let (from, to) = (a0.max(c0), a1.min(c1)); + let len = |x: f64, y: f64| s.subsegment(x..y).arclen(1e-6); + let shared = if a != c && from < to { len(from, to) } else { 0.0 }; + let short_shared = a_close && c_close && shared > 0.5 * len(a0, a1).min(len(c0, c1)); + if shared > 0.0 && (shared > LP_FACE_EDGE_TOL || short_shared) { + next.entry(a).or_default().push(c); + next.entry(c).or_default().push(a); + } + } + } + } + let mut reached = vec![start]; + let mut seen = HashSet::from([start]); + let mut i = 0; + while let Some(&f) = reached.get(i) { + i += 1; + for &n in next.get(&f).map(Vec::as_slice).unwrap_or_default() { + if seen.insert(n) { + reached.push(n); + } + } + } + reached +} + +/// The part of edge segment `s` that outline segment `piece` runs along, as (from, to) in `s`'s +/// own parameter, or None when they share no length. The part runs between the ends of `s` that lie +/// within [`LP_FACE_EDGE_TOL`] of `piece` and the ends of `piece` that lie within [`LP_JOIN_TOL`] of +/// `s` (the map keeps the ends of an outline segment it refits), and its middle lies within +/// [`LP_FACE_EDGE_TOL`] of `piece`, where the two run along each other ([`lp_runs_along`]). When no +/// end of `piece` lies on `s`, `piece` must lie within [`LP_JOIN_TOL`] of the part ([`lp_close`]). So +/// two segments that only cross or touch, or that only lie near each other, share nothing. +fn lp_overlap(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> { + use kurbo::{ParamCurve, ParamCurveNearest}; + let on_piece = |p: Point| piece.nearest(p, 1e-9).distance_sq.sqrt() <= LP_FACE_EDGE_TOL; + let mut ts: Vec = [(s.p0, 0.0), (s.p3, 1.0)].into_iter().filter(|(p, _)| on_piece(*p)).map(|(_, t)| t).collect(); + let mut piece_end_on = false; + for p in [piece.p0, piece.p3] { + let n = s.nearest(p, 1e-9); + if n.distance_sq.sqrt() <= LP_JOIN_TOL { + ts.push(n.t); + piece_end_on = true; + } + } + let from = ts.iter().copied().fold(f64::INFINITY, f64::min); + let to = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if from >= to { + return None; + } + let mid = (from + to) / 2.0; + let n = piece.nearest(s.eval(mid), 1e-9); + let along = if piece_end_on { lp_runs_along(s, from, to, piece, mid, n.t) } else { lp_close(s, from, to, piece) }; + (n.distance_sq.sqrt() <= LP_FACE_EDGE_TOL && along).then_some((from, to)) +} + +/// The part of edge segment `s` that outline segment `piece` lies on as a whole, as (from, to) in +/// `s`'s own parameter: both ends of `piece` lie within [`LP_JOIN_TOL`] of `s`, its middle lies +/// within [`LP_FACE_EDGE_TOL`] of `s`, and the two run along each other ([`lp_runs_along`]). +fn lp_lies_on(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> { + use kurbo::{ParamCurve, ParamCurveNearest}; + let (a, c, m) = (s.nearest(piece.p0, 1e-9), s.nearest(piece.p3, 1e-9), s.nearest(piece.eval(0.5), 1e-9)); + let (from, to) = (a.t.min(c.t), a.t.max(c.t)); + let near = a.distance_sq.sqrt() <= LP_JOIN_TOL && c.distance_sq.sqrt() <= LP_JOIN_TOL && m.distance_sq.sqrt() <= LP_FACE_EDGE_TOL; + (near && lp_runs_along(s, from, to, piece, m.t, 0.5)).then_some((from, to)) +} + +/// Whether outline segment `piece` runs along the part of edge segment `s` from `from` to `to` +/// rather than crossing it: it lies within [`LP_JOIN_TOL`] of that part ([`lp_close`]), or the two +/// run the same way or opposite ways at `ts` on `s` and `tp` on `piece` ([`lp_parallel`]). +fn lp_runs_along(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez, ts: f64, tp: f64) -> bool { + lp_close(s, from, to, piece) || lp_parallel(s, ts, piece, tp) +} + +/// Whether outline segment `piece` lies within [`LP_JOIN_TOL`] of edge segment `s` at the ends and +/// the middle of the part of `s` from `from` to `to`. +fn lp_close(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez) -> bool { + use kurbo::{ParamCurve, ParamCurveNearest}; + [from, (from + to) / 2.0, to].into_iter().all(|t| piece.nearest(s.eval(t), 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL) +} + +/// Whether segment `s` at parameter `ts` and segment `piece` at parameter `tp` run the same way or +/// opposite ways: the sine of the angle between their directions there is at most 0.2. False where +/// either has no direction. +fn lp_parallel(s: &kurbo::CubicBez, ts: f64, piece: &kurbo::CubicBez, tp: f64) -> bool { + use kurbo::{ParamCurve, ParamCurveDeriv}; + let (u, v) = (s.deriv().eval(ts).to_vec2(), piece.deriv().eval(tp).to_vec2()); + let n = u.hypot() * v.hypot(); + n > 0.0 && u.cross(v).abs() <= 0.2 * n +} + +/// Most grid cells one edge segment is listed in by [`LpEdgeGrid`]; a longer one is tried for +/// every point. +const LP_GRID_SPAN: i64 = 1024; + +/// The farthest cell from the grid's origin, on each axis, that [`LpEdgeGrid`] numbers a point in. +const LP_GRID_LIMIT: f64 = 1_048_576.0; + +/// The edge segments a flood crosses, by the cells of a square grid (about one cell per segment) +/// that their bounds, widened by [`LP_FACE_EDGE_TOL`], cover. +struct LpEdgeGrid { + origin: Point, + cell: f64, + /// Segment indices by cell. + cells: std::collections::HashMap<(i64, i64), Vec>, + /// Segments whose bounds cover more than [`LP_GRID_SPAN`] cells, near every point. + wide: Vec, +} + +impl LpEdgeGrid { + /// The grid of the segments of `edges` that a flood crosses (those marked true). + fn new(edges: &[(kurbo::CubicBez, bool)]) -> Self { + let boxes: Vec<(usize, Rect)> = edges + .iter() + .enumerate() + .filter(|(_, e)| e.1) + .map(|(k, (s, _))| (k, kurbo::ParamCurveExtrema::bounding_box(s).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL))) + .collect(); + let all = boxes.iter().map(|b| b.1).reduce(|a, c| a.union(c)).unwrap_or_default(); + let side = (boxes.len() as f64).sqrt().ceil().max(1.0); + let cell = all.width().max(all.height()) / side; + let cell = if cell.is_finite() && cell > LP_FACE_EDGE_TOL { cell } else { LP_FACE_EDGE_TOL }; + let mut grid = Self { origin: all.origin(), cell, cells: std::collections::HashMap::new(), wide: vec![] }; + for (k, b) in boxes { + let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1))); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN { + grid.wide.push(k); + continue; + } + for x in x0..=x1 { + for y in y0..=y1 { + grid.cells.entry((x, y)).or_default().push(k); + } + } + } + grid + } + + /// The cell `p` is in, counted from the origin (at most [`LP_GRID_LIMIT`] cells away on each + /// axis). + fn cell_of(&self, p: Point) -> (i64, i64) { + let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-LP_GRID_LIMIT, LP_GRID_LIMIT) as i64; + (at(p.x, self.origin.x), at(p.y, self.origin.y)) + } + + /// The segments whose widened bounds may meet `r`, each once, in ascending order. When `r` + /// covers more than [`LP_GRID_SPAN`] cells, every listed segment is returned. + fn near_box(&self, r: Rect) -> Vec { + let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(r.x0, r.y0)), self.cell_of(Point::new(r.x1, r.y1))); + let mut out: Vec = self.wide.clone(); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN { + out.extend(self.cells.values().flatten()); + } else { + for x in x0..=x1 { + for y in y0..=y1 { + out.extend(self.cells.get(&(x, y)).into_iter().flatten()); + } + } + } + out.sort_unstable(); + out.dedup(); + out + } +} + +/// The edges of Live Paint group `group` that are not hidden and are joined to edge `start` through +/// edges that draw with stroke paint `paint` ([`lp_stroke_paint`]), `start` first: `start`, and each +/// edge that draws with that stroke paint and has an end (the first or last point of one of its subpaths) within [`LP_JOIN_TOL`] of an +/// end of an edge already reached. +/// Empty when `start` is not an edge of `group` that is not hidden. +pub fn connected_edges(group: &Node, start: NodeId, paint: &Paint) -> Vec { + use std::collections::{HashMap, HashSet}; + let es: Vec<&Arc> = edges(group).into_iter().filter(|e| e.visible && (e.id == start || lp_stroke_paint(e) == *paint)).collect(); + // The first and last points of each subpath of each edge. + let ends: Vec> = es + .iter() + .map(|e| { + let Some(pd) = e.path_data() else { return vec![] }; + pd.subpaths + .iter() + .filter_map(|sp| { + let n = sp.segment_count(); + (n > 0).then(|| [sp.segment(0).p0, sp.segment(n - 1).p3]) + }) + .flatten() + .collect() + }) + .collect(); + let mut at: HashMap<(i64, i64), Vec<(Point, usize)>> = HashMap::new(); + for (i, pts) in ends.iter().enumerate() { + for &p in pts { + at.entry(lp_cell(p)).or_default().push((p, i)); + } + } + let Some(first) = es.iter().position(|e| e.id == start) else { return vec![] }; + let mut reached = vec![first]; + let mut seen = HashSet::from([first]); + let mut k = 0; + while let Some(&i) = reached.get(k) { + k += 1; + for &p in ends.get(i).map(Vec::as_slice).unwrap_or_default() { + for c in lp_cells_around(p) { + for &(q, j) in at.get(&c).map(Vec::as_slice).unwrap_or_default() { + if (q - p).hypot() <= LP_JOIN_TOL && seen.insert(j) { + reached.push(j); + } + } + } + } + } + reached.into_iter().filter_map(|i| es.get(i).map(|e| e.id)).collect() +} + /// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). pub fn live_paint_at(doc: &Document, p: Point) -> Option { live_paint_at_where(doc, p, |id| doc.is_visible(id)) diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 298e6a1907..0d8d12f183 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1636,6 +1636,583 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); } +/// A Live Paint group of three faces in a row, A (0..50), B (50..100) and C (100..150), 50 pt tall, +/// with these edges: AB between A and B, with no stroke; BC between B and C, stroked red; the tops +/// of A and B stroked black; the top of C stroked red; and one bottom edge under all three, +/// stroked black. Returns the group, the faces [A, B, C] and the edges [AB, BC, top A, top B, +/// top C, bottom]. +fn live_strip(d: &mut Document) -> (NodeId, [NodeId; 3], [NodeId; 6]) { + let l = d.layers[0].id; + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let mut kids = vec![empty_sources(d)]; + let mut fs = [NodeId(0); 3]; + for (k, x) in [0.0, 50.0, 100.0].into_iter().enumerate() { + fs[k] = d.alloc_id(); + let mut face = Node::path(fs[k], shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + } + let pt = Point::new; + let lines = [ + (pt(50.0, 0.0), pt(50.0, 50.0), None), + (pt(100.0, 0.0), pt(100.0, 50.0), Some(red.clone())), + (pt(0.0, 0.0), pt(50.0, 0.0), Some(black.clone())), + (pt(50.0, 0.0), pt(100.0, 0.0), Some(black.clone())), + (pt(100.0, 0.0), pt(150.0, 0.0), Some(red)), + (pt(0.0, 50.0), pt(150.0, 50.0), Some(black)), + ]; + let mut es = [NodeId(0); 6]; + for (k, (from, to, stroke)) in lines.into_iter().enumerate() { + es[k] = d.alloc_id(); + let mut a = Appearance::default(); + if let Some(s) = stroke { + a.set_stroke(s); + } + let mut edge = Node::path(es[k], shapes::line(from, to), a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + let at = d.layers[0].children().map_or(0, |c| c.len()); + d.insert(Some(l), at, g).unwrap(); + (gid, fs, es) +} + +#[test] +fn live_paint_flood_crosses_unstroked_edges_and_stops_at_stroked_ones() { + let mut d = Document::new(500.0, 500.0); + let (g, [a, b, c], [ab, bc, ..]) = live_strip(&mut d); + let group = |d: &Document| d.node(g).unwrap().clone(); + assert_eq!(flood_faces(&group(&d), a), vec![a, b]); + assert_eq!(flood_faces(&group(&d), b), vec![b, a]); + assert_eq!(flood_faces(&group(&d), c), vec![c]); + // A hidden edge does not part the faces either; a hidden face is not reached and does not start + // a flood; an id that is not a face of the group reaches nothing. + let mut hidden = d.clone(); + hidden.node_mut(bc).unwrap().visible = false; + assert_eq!(flood_faces(&group(&hidden), a), vec![a, b, c]); + hidden.node_mut(b).unwrap().visible = false; + assert_eq!(flood_faces(&group(&hidden), a), vec![a]); + assert!(flood_faces(&group(&hidden), b).is_empty()); + assert!(flood_faces(&group(&d), ab).is_empty()); + // An edge whose stroke is hidden, 0 pt wide or fully transparent, or that is itself fully + // transparent, draws no stroke, so the flood crosses it. + use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; + let base = d.node(bc).unwrap().appearance.stroke().unwrap().clone(); + let changes: [fn(&mut StrokeLayer); 3] = [|s| s.visible = false, |s| s.width = 0.0, |s| s.opacity = 0.0]; + for change in changes { + let mut e = d.clone(); + let mut s = base.clone(); + change(&mut s); + e.node_mut(bc).unwrap().appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_))); + e.node_mut(bc).unwrap().appearance.items.push(AppearanceItem::Stroke(s)); + assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]); + } + let mut e = d.clone(); + e.node_mut(bc).unwrap().opacity = 0.0; + assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]); +} + +/// Faces P (0..50) and Q (50..100) above one edge from (0, 0) to (100, 0) and face R (0..100) below +/// it, with no edge between P and Q (as where a Gap Detection line parts them): P and Q each run +/// along part of the edge, and R along all of it. +#[test] +fn live_paint_flood_measures_faces_along_part_of_an_edge() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = [NodeId(0); 3]; + for (k, r) in [Rect::new(0.0, -50.0, 50.0, 0.0), Rect::new(50.0, -50.0, 100.0, 0.0), Rect::new(0.0, 0.0, 100.0, 50.0)].into_iter().enumerate() { + ids[k] = d.alloc_id(); + let mut face = Node::path(ids[k], shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + } + let [p, q, r] = ids; + let mut edge = Node::path(d.alloc_id(), shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + // P reaches R across the edge, and Q through R; P and Q only touch at (50, 0). + assert_eq!(flood_faces(&group, p), vec![p, r, q]); + let mut without_r = d.clone(); + without_r.node_mut(r).unwrap().visible = false; + assert_eq!(flood_faces(without_r.node(gid).unwrap(), p), vec![p]); +} + +/// Face P (0..100) above unstroked edges along y = 0 split at `splits`, whose outline runs along +/// all of them with one segment, and below them one face between each two splits, the faces parted +/// from each other by stroked edges. A flood from any face below reaches P and, through it, the +/// others; this holds when the outline's middle is no edge's middle (30), is the end shared by two +/// edges (50), and is the middle of an edge shorter than the outline (25 and 75). +#[test] +fn live_paint_flood_crosses_an_outline_segment_that_spans_several_edges() { + for splits in [vec![30.0], vec![50.0], vec![25.0, 75.0]] { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut kids = vec![empty_sources(&mut d)]; + let xs: Vec = std::iter::once(0.0).chain(splits.iter().copied()).chain(std::iter::once(100.0)).collect(); + let add_face = |d: &mut Document, kids: &mut Vec>, r: Rect| { + let id = d.alloc_id(); + let mut face = Node::path(id, shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + id + }; + let p = add_face(&mut d, &mut kids, Rect::new(0.0, -50.0, 100.0, 0.0)); + let below: Vec = xs.windows(2).map(|w| add_face(&mut d, &mut kids, Rect::new(w[0], 0.0, w[1], 50.0))).collect(); + let pt = Point::new; + let lines = xs + .windows(2) + .map(|w| (pt(w[0], 0.0), pt(w[1], 0.0), false)) + .chain(splits.iter().map(|&x| (pt(x, 0.0), pt(x, 50.0), true))) + .collect::>(); + for (from, to, stroked) in lines { + let mut a = Appearance::default(); + if stroked { + a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + } + let mut edge = Node::path(d.alloc_id(), shapes::line(from, to), a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + let mut all = below.clone(); + all.push(p); + all.sort(); + for &f in &below { + let mut got = flood_faces(&group, f); + assert_eq!(got.first(), Some(&f), "{splits:?}"); + got.sort(); + assert_eq!(got, all, "{splits:?} from {f:?}"); + } + } +} + +/// A Live Paint group built from `paths` as `livePaint.make` builds one, with Gap Detection closing +/// gaps up to `gap` pt when given, and with no stroke on any edge. Returns the group and its faces. +fn live_from_paths(paths: Vec, gap: Option) -> (Node, Vec) { + let mut d = Document::new(500.0, 500.0); + let shapes: Vec = paths.into_iter().enumerate().map(|(k, p)| po::Shape::new(p, FillRule::NonZero, k as u64)).collect(); + let (regions, lines) = match gap { + Some(g) => { + let (r, e, _) = po::live_paint_with_gaps(&shapes, g); + (r, e) + } + None => po::live_paint(&shapes), + }; + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = vec![]; + for r in regions { + let id = d.alloc_id(); + let mut face = Node::path(id, r.path, Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + ids.push(id); + } + for e in lines { + let mut edge = Node::path(d.alloc_id(), e.path, Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let mut g = Node::group(d.alloc_id(), kids); + g.name = Some(LIVE_PAINT_NAME.into()); + (g, ids) +} + +/// Live Paint groups built from curves, one with a T-junction where lines end inside an ellipse and +/// one with Gap Detection closing the gap between a curve's end and a circle: with no stroke on any +/// edge, a flood from any face reaches every face. +#[test] +fn live_paint_flood_crosses_unstroked_curves_in_built_groups() { + let pt = Point::new; + let frame = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)); + let t_junction = vec![ + frame, + shapes::ellipse(Rect::new(50.0, 60.0, 150.0, 140.0)), + shapes::line(pt(113.0, 0.0), pt(113.0, 80.0)), + shapes::line(pt(113.0, 200.0), pt(113.0, 120.0)), + ]; + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Smooth }; + let (start, end) = (pt(102.7, 20.0), pt(109.0, 100.0 + (2500.0f64 - 81.0).sqrt() - 2.0)); + let curve = PathData::single(SubPath::new( + vec![a(start, start, start + kurbo::Vec2::new(40.0, 40.0)), a(end, end + kurbo::Vec2::new(-30.0, -40.0), end)], + false, + )); + let gap = vec![shapes::ellipse(Rect::new(50.0, 50.0, 150.0, 150.0)), curve]; + for (name, paths, max_gap, count) in [("T-junction", t_junction, None, 3), ("Gap Detection", gap, Some(3.0), 2)] { + let (g, fs) = live_from_paths(paths, max_gap); + assert_eq!(fs.len(), count, "{name}"); + for &f in &fs { + let mut got = flood_faces(&g, f); + got.sort(); + assert_eq!(got, fs, "{name} from {f:?}"); + } + } +} + +/// A closed outline: `curve`, then straight lines through `rest` and back to the curve's start. +fn curve_then_lines(curve: kurbo::CubicBez, rest: &[Point]) -> PathData { + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + let mut anchors = vec![a(curve.p0, curve.p0, curve.p1), a(curve.p3, curve.p2, curve.p3)]; + anchors.extend(rest.iter().map(|&p| a(p, p, p))); + PathData::single(SubPath::new(anchors, true)) +} + +/// `c` with its two handles moved 0.006 pt along y: its ends stay put and its middle moves 0.0045 pt, +/// as a face outline refit within the planar map's precision can lie off its edge. +fn refit(c: kurbo::CubicBez) -> kurbo::CubicBez { + let up = kurbo::Vec2::new(0.0, 0.006); + kurbo::CubicBez::new(c.p0, c.p1 + up, c.p2 + up, c.p3) +} + +/// A Live Paint group of `faces` and of `lines`, each line with or without a stroke. Returns the +/// group and the ids of the faces. +fn live_of(faces: Vec, lines: Vec<(PathData, bool)>) -> (Node, Vec) { + let mut d = Document::new(500.0, 500.0); + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = vec![]; + for pd in faces { + let id = d.alloc_id(); + let mut face = Node::path(id, pd, Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + ids.push(id); + } + for (pd, stroked) in lines { + let mut a = Appearance::default(); + if stroked { + a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + } + let mut edge = Node::path(d.alloc_id(), pd, a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let mut g = Node::group(d.alloc_id(), kids); + g.name = Some(LIVE_PAINT_NAME.into()); + (g, ids) +} + +/// Faces whose outlines along an unstroked curved edge lie up to 0.0045 pt off it, as the planar map +/// can leave them when it refits an outline across a T-junction or, with Gap Detection on, builds the +/// faces and the edges from two maps: the flood still crosses the edge. Two faces that a gap parts +/// on the same side of the edge, which only touch, are still not joined. +#[test] +fn live_paint_flood_crosses_curved_edges_that_outlines_lie_slightly_off() { + use kurbo::ParamCurve; + let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(30.0, 40.0), Point::new(70.0, 40.0), Point::new(100.0, 0.0)); + let edge = |s: kurbo::CubicBez| { + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + PathData::single(SubPath::new(vec![a(s.p0, s.p0, s.p1), a(s.p3, s.p2, s.p3)], false)) + }; + // T-junction: a stroked line ends on the curve at t = 0.4 and parts the faces below it (L and R); + // the curve is two edges, and the face above (U) runs along both with one refit segment. + let j = c.eval(0.4); + let (left, right) = (c.subsegment(0.0..0.4), c.subsegment(0.4..1.0)); + let (g, ids) = live_of( + vec![ + curve_then_lines(left, &[Point::new(j.x, 80.0), Point::new(0.0, 80.0)]), + curve_then_lines(right, &[Point::new(100.0, 80.0), Point::new(j.x, 80.0)]), + curve_then_lines(refit(c), &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]), + ], + vec![(edge(left), false), (edge(right), false), (shapes::line(j, Point::new(j.x, 80.0)), true)], + ); + let [l, r, u] = [ids[0], ids[1], ids[2]]; + assert_eq!(flood_faces(&g, l), vec![l, u, r]); + let mut from_u = flood_faces(&g, u); + assert_eq!(from_u.first(), Some(&u)); + from_u.sort(); + assert_eq!(from_u, ids); + // Gap Detection: a gap line, which is no edge, parts P and Q above the curve at t = 0.5; the curve + // is one edge, and Q and the face below (B) run along it with refit segments. + let m = c.eval(0.5); + let (g, ids) = live_of( + vec![ + curve_then_lines(c.subsegment(0.0..0.5), &[Point::new(m.x, -50.0), Point::new(0.0, -50.0)]), + curve_then_lines(refit(c.subsegment(0.5..1.0)), &[Point::new(100.0, -50.0), Point::new(m.x, -50.0)]), + curve_then_lines(refit(c), &[Point::new(100.0, 80.0), Point::new(0.0, 80.0)]), + ], + vec![(edge(c), false)], + ); + let [p, q, b] = [ids[0], ids[1], ids[2]]; + assert_eq!(flood_faces(&g, p), vec![p, b, q]); + let mut without_b = g.clone(); + if let Some(kids) = without_b.children_mut() + && let Some(n) = kids.iter_mut().find(|n| n.id == b) + { + Arc::make_mut(n).visible = false; + } + assert_eq!(flood_faces(&without_b, p), vec![p]); +} + +/// Two large faces whose only shared edge, with no stroke, is 0.005 pt long: the flood crosses it. +#[test] +fn live_paint_flood_crosses_a_shared_edge_shorter_than_the_precision() { + let pt = Point::new; + let (a, b) = (pt(50.0, 0.0), pt(50.0, 0.005)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[pt(0.0, -50.0), a, b, pt(0.0, 50.0)], true)), + PathData::single(SubPath::polyline(&[pt(100.0, -50.0), pt(100.0, 50.0), b, a], true)), + ], + vec![(shapes::line(a, b), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// A stroked edge along y = 0 and an edge with no stroke 0.008 pt below it, with a face above the +/// first whose outline ends on it and bows 0.005 pt off it in the middle, nearer the second, and a +/// face below the second: the stroked edge parts the two faces, so the flood does not join them. +#[test] +fn live_paint_flood_stops_where_a_stroked_edge_runs_along_an_unstroked_one() { + let pt = Point::new; + let bow = kurbo::CubicBez::new(pt(100.0, 0.0), pt(70.0, 0.02 / 3.0), pt(30.0, 0.02 / 3.0), pt(0.0, 0.0)); + let (g, ids) = live_of( + vec![ + curve_then_lines(bow, &[pt(0.0, -50.0), pt(100.0, -50.0)]), + PathData::single(SubPath::polyline(&[pt(0.0, 0.008), pt(0.0, 50.0), pt(100.0, 50.0), pt(100.0, 0.008)], true)), + ], + vec![(shapes::line(pt(0.0, 0.0), pt(100.0, 0.0)), true), (shapes::line(pt(0.0, 0.008), pt(100.0, 0.008)), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]); + assert_eq!(flood_faces(&g, ids[1]), vec![ids[1]]); +} + +/// An edge with no stroke from (10, 0) to (90, 0), and two faces that meet on a line 0.005 pt below +/// it from (0, 0.005) to (100, 0.005) (as a Gap Detection line): neither face runs along the edge, so +/// the flood does not join them. +#[test] +fn live_paint_flood_does_not_cross_an_edge_beside_a_line_two_faces_meet_on() { + let pt = Point::new; + let (a, b) = (pt(0.0, 0.005), pt(100.0, 0.005)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[a, b, pt(100.0, -50.0), pt(0.0, -50.0)], true)), + PathData::single(SubPath::polyline(&[b, a, pt(0.0, 50.0), pt(100.0, 50.0)], true)), + ], + vec![(shapes::line(pt(10.0, 0.0), pt(90.0, 0.0)), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]); +} + +/// A stroked edge and an edge with no stroke that leave one point at a slope of 0.01, so they lie +/// within 0.01 pt of each other for their first point or so: the face between them and the face +/// beyond the edge with no stroke share that edge, so the flood joins them. +#[test] +fn live_paint_flood_crosses_an_unstroked_edge_beside_a_stroked_one() { + let pt = Point::new; + let (o, a, b) = (pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 1.0)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[o, a, b], true)), + PathData::single(SubPath::polyline(&[o, b, pt(100.0, 50.0), pt(0.0, 50.0)], true)), + ], + vec![(shapes::line(o, a), true), (shapes::line(o, b), false), (shapes::line(a, b), true)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// Two faces on either side of an edge with no stroke whose curve has a cusp at its middle (no +/// direction there): the flood crosses it. +#[test] +fn live_paint_flood_crosses_an_edge_with_a_cusp_at_its_middle() { + let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0), Point::new(100.0, 0.0)); + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + let edge = PathData::single(SubPath::new(vec![a(c.p0, c.p0, c.p1), a(c.p3, c.p2, c.p3)], false)); + let (g, ids) = live_of( + vec![ + curve_then_lines(c, &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]), + curve_then_lines(c, &[Point::new(100.0, 100.0), Point::new(0.0, 100.0)]), + ], + vec![(edge, false)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// Two faces parted by a line that is no edge (as a Gap Detection line), which crosses an edge with +/// no stroke: one 0.006 pt long, upright or at a slope of 0.1, and one 1 pt long at a slope of 0.09. +/// The faces only cross the edge and do not run along it, so the flood does not join them. +#[test] +fn live_paint_flood_does_not_cross_an_edge_an_outline_only_crosses() { + let pt = Point::new; + let short = pt(0.006, 0.0); + for (a, b, end) in + [(pt(0.003, -0.004), pt(0.003, 0.02), short), (pt(-0.037, -0.004), pt(0.043, 0.004), short), (pt(0.1, -0.009), pt(0.3, 0.009), pt(1.0, 0.0))] + { + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[a, b, pt(-50.0, 50.0), pt(-50.0, -50.0)], true)), + PathData::single(SubPath::polyline(&[b, a, pt(50.0, -50.0), pt(50.0, 50.0)], true)), + ], + vec![(shapes::line(pt(0.0, 0.0), end), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]], "{a:?} to {b:?}"); + } +} + +#[test] +fn live_paint_connected_edges_follow_the_same_stroke_paint() { + let mut d = Document::new(500.0, 500.0); + let (g, [a, ..], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let group = d.node(g).unwrap().clone(); + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + // Top A and top B join end to end; top C joins them but is red, and the black bottom does not + // join them. + assert_eq!(connected_edges(&group, top_a, &black), vec![top_a, top_b]); + // BC and top C, both red, join at (100, 0). + assert_eq!(connected_edges(&group, bc, &red), vec![bc, top_c]); + assert_eq!(connected_edges(&group, bottom, &black), vec![bottom]); + // The edge started from is taken whatever its paint. + assert_eq!(connected_edges(&group, ab, &Paint::None), vec![ab]); + assert!(connected_edges(&group, a, &black).is_empty()); +} + +/// An edge from (0, 0) through (50, 0) to (100, 0) and a second edge with the same stroke from +/// (50, 0) to (50, 50): the second one ends at an anchor in the middle of the first, not at one of +/// its ends, so the two are not joined end to end. A third edge from (100, 0) is. +#[test] +fn live_paint_connected_edges_join_only_at_path_ends() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let mut kids = vec![empty_sources(&mut d)]; + let pt = Point::new; + let paths = [ + PathData::single(SubPath::polyline(&[pt(0.0, 0.0), pt(50.0, 0.0), pt(100.0, 0.0)], false)), + shapes::line(pt(50.0, 0.0), pt(50.0, 50.0)), + shapes::line(pt(100.0, 0.0), pt(100.0, 50.0)), + ]; + let mut ids = vec![]; + for pd in paths { + let mut a = Appearance::default(); + a.set_stroke(black.clone()); + let id = d.alloc_id(); + let mut edge = Node::path(id, pd, a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + ids.push(id); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + assert_eq!(connected_edges(&group, ids[0], &black), vec![ids[0], ids[2]]); + assert_eq!(connected_edges(&group, ids[1], &black), vec![ids[1]]); +} + +/// Three edges end to end along (0, 0) to (150, 0) under one face: a red one, one drawn red with a +/// hidden blue stroke over it, and one drawn blue with a hidden red stroke over it. Edges join by the +/// paint they draw with, not by a hidden stroke's paint, for `connected_edges` and a double-click. +#[test] +fn live_paint_connected_edges_follow_the_stroke_paint_they_draw_with() { + use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + let mut kids = vec![empty_sources(&mut d)]; + let fid = d.alloc_id(); + let mut face = Node::path(fid, shapes::rectangle(Rect::new(0.0, 0.0, 150.0, 50.0)), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + let pt = Point::new; + let hidden = |p: &Paint| AppearanceItem::Stroke(StrokeLayer { visible: false, ..StrokeLayer::new(p.clone(), 1.0) }); + let shown = |p: &Paint| AppearanceItem::Stroke(StrokeLayer::new(p.clone(), 1.0)); + let edges = [(0.0, vec![shown(&red)]), (50.0, vec![shown(&red), hidden(&blue)]), (100.0, vec![shown(&blue), hidden(&red)])]; + let mut ids = vec![]; + for (x, items) in edges { + let id = d.alloc_id(); + let mut edge = Node::path(id, shapes::line(pt(x, 0.0), pt(x + 50.0, 0.0)), Appearance { items, ..Appearance::default() }); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + ids.push(id); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + assert_eq!(connected_edges(&group, ids[0], &red), vec![ids[0], ids[1]]); + assert_eq!(connected_edges(&group, ids[2], &blue), vec![ids[2]]); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let mut t = LivePaintSelectionTool::default(); + let mut dbl = |x: f64| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, 1.0).with_mods(alt)); + assert_eq!(dbl(75.0), set(&[ids[1], ids[0]])); + assert_eq!(dbl(125.0), set(&[ids[2]])); +} + +#[test] +fn live_paint_selection_double_click_selects_connected_faces_or_edges() { + let mut d = Document::new(500.0, 500.0); + let (_, [a, b, c], [_, _, top_a, top_b, ..]) = live_strip(&mut d); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let dbl = |t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, y).with_mods(m)); + let mut t = LivePaintSelectionTool::default(); + // A face: the faces not parted from it by a stroked edge. + assert_eq!(dbl(&mut t, 25.0, 25.0, Mods::default()), set(&[a, b])); + assert_eq!(dbl(&mut t, 125.0, 25.0, Mods::default()), set(&[c])); + // An edge (Alt): the edges joined to it end to end with its stroke paint. + assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[top_a, top_b])); + // With Shift held, the parts reached leave the selection or join it. + assert_eq!(dbl(&mut t, 75.0, 25.0, shift), toggle(&[b, a])); + assert_eq!(dbl(&mut t, 75.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_b, top_a])); + // Away from the group, a double-click adds nothing to what its clicks did. + assert!(dbl(&mut t, 300.0, 300.0, Mods::default()).is_empty()); + // Select Fills off: the double-click picks the edge nearest the pointer as a click does. + t.set_option("selectFills", &json!(false)); + assert_eq!(dbl(&mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b])); + assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty()); + // Select Strokes off: Alt picks the face. + t.set_option("selectFills", &json!(true)); + t.set_option("selectStrokes", &json!(false)); + assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[a, b])); + // Both off: nothing. + t.set_option("selectFills", &json!(false)); + assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty()); + assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty()); +} + +#[test] +fn live_paint_selection_double_click_skips_locked_groups_and_art_outside_isolation() { + let mut d = Document::new(500.0, 500.0); + let (g, _, _) = live_strip(&mut d); + let (other, _) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let dbl = |d: &Document, iso: Option, x: f64| { + let cx = ToolContext { isolation: iso, ..cx(d, &s, &p) }; + LivePaintSelectionTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, x, 25.0)) + }; + assert!(!dbl(&d, None, 25.0).is_empty()); + assert!(dbl(&d, Some(other), 25.0).is_empty(), "outside the isolated art"); + d.node_mut(g).unwrap().locked = true; + assert!(dbl(&d, None, 25.0).is_empty(), "a locked group"); + assert!(!dbl(&d, None, 250.0).is_empty()); +} + /// The Live Paint Selection pointer shows what a click at the point selects, from the point and /// the modifier keys alone (no move first): a face, an edge, or nothing. Groups the tool does not /// take (locked, hidden, outside isolation) show nothing to select. A press keeps its look until diff --git a/docs/mcp.md b/docs/mcp.md index 912ac32562..5826ace727 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3491,6 +3491,16 @@ already walks Image Trace. Short agent entry points: a locked artboard, or in isolation mode outside the isolated art. In the app, the tool's cursor shows what a click at the pointer selects under these rules: the arrow with a filled square for a face, with a short line for an edge, and with an x for nothing. It keeps the look it had at the press until the release. + A double-click (`pointer_gesture` kind `doubleclick`, after the two clicks' `down` and `up`) on the face a click there + picks runs `select.set {ids}` with that face and the faces of its group that a flood from it reaches across edges + that are hidden or draw no stroke (an edge that is fully transparent, or whose strokes are each hidden, without + paint, 0 pt wide or fully transparent); a stroked edge and a Gap Detection gap stop the flood, and hidden faces are + left out. On the edge a click + there picks, it runs `select.set {ids}` with that edge and the edges of its group, not hidden, joined to it end to + end through edges that draw with the stroke paint it draws with (the paint of an edge's topmost stroke that is + shown, with paint, above 0 pt and not fully transparent; none for an edge that draws no stroke). With Shift held it runs `select.toggle {ids}` with the same faces or edges, + and where a click selects no face or edge it runs nothing. The same options, locks and isolation rules apply as for + a click. The Live Paint Bucket's options (`tool.setOption {tool: "livePaintBucket", values}`; double-clicking the tool opens them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `highlight` (on), `color` (the highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the face highlight's width, 1 to 20 screen diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 26681e5b7f..2280ab468d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1433,6 +1433,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`. A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. A drag with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. + A double-click with the Live Paint Selection tool on a face selects it and the faces of its group that no stroked edge parts from it ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)), and on an edge selects it and the edges joined to it end to end that have its stroke paint ([the help page](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)); Shift-double-click adds them to the selection or removes them from it. Triple-click is not handled yet. When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. From 8a449cb98edf71e59289a414f150e21435a07301 Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:45:40 +0100 Subject: [PATCH 07/89] Pencil: badged cursors keep their badge with Use Precise Cursors The Pencil's and the Paintbrush's new-path, continue, close and straight-line cursors are drawn on the crosshair already; Precise Cursors no longer turns them into the plain crosshair, so the badge still says what the press or the release does. --- crates/tools/src/cursor.rs | 9 +++------ crates/tools/src/cursors.toml | 13 +++++++++++-- crates/tools/src/draw2/gesture/tests.rs | 4 ++-- crates/tools/src/lib.rs | 5 ++++- docs/ui-parity.md | 2 +- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 85b9cf258e..56e2cd2ae7 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -259,7 +259,9 @@ impl Cursor { /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools', the Live - /// Paint tools') become a plain crosshair at the hotspot. + /// Paint tools') become a plain crosshair at the hotspot. The Pencil's and the Paintbrush's + /// badged cursors are drawn on the crosshair already and keep their badge, which says what the + /// press or the release does (a new path, continuing, closing, a straight line). pub fn precise(self) -> Self { match self { Cursor::Pen @@ -282,12 +284,7 @@ impl Cursor { | Cursor::Lasso | Cursor::MagicWand | Cursor::Pencil - | Cursor::PencilNew - | Cursor::PencilClose - | Cursor::PencilStraight - | Cursor::PencilContinue | Cursor::Paintbrush - | Cursor::PaintbrushClose | Cursor::BlobBrush | Cursor::Eraser | Cursor::Smooth diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 1c15b37d71..efa31a515d 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -628,7 +628,10 @@ hotspot = "The lead tip." [[cursor]] id = "PencilNew" group = "Drawing and editing tools" -shown_when = ["The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star."] +shown_when = [ + "The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star.", + "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", +] hotspot = "The lead tip." look = "The Pencil cursor with a small star badge, where PencilClose has its ring." @@ -639,6 +642,7 @@ shown_when = [ "The Pencil tool with Alt pressed after the drag starts, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path. A drag started with Shift or Alt held draws an open straight line and does not show this cursor.", "The Pencil tool while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.", "The Pencil tool while a stroke continuing a selected open path, freehand or a straight line, is drawn to within Close Paths When Ends Are Within of that path's other end: releasing the mouse button there closes the path.", + "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", ] hotspot = "The lead tip." look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." @@ -646,7 +650,10 @@ look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." [[cursor]] id = "PencilStraight" group = "Drawing and editing tools" -shown_when = ["The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone."] +shown_when = [ + "The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone.", + "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", +] hotspot = "The lead tip." look = "The Pencil cursor with a short level-line badge, where PencilClose has its ring." @@ -656,6 +663,7 @@ group = "Drawing and editing tools" shown_when = [ "The Pencil tool over an end of a selected open path, within Edit Selected Paths, before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press continues the path from that end, freehand, or with a straight line when Shift or Alt is held.", "The Pencil tool while a stroke continuing a selected path ends within Edit Selected Paths of an open end of another path selected at the press: releasing the mouse button connects the two.", + "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", ] hotspot = "The lead tip." look = "The Pencil cursor with a short badge line rising to the right, where PencilClose has its ring (the stroke carries on from the path)." @@ -672,6 +680,7 @@ group = "Drawing and editing tools" shown_when = [ "The Paintbrush tool with Alt held while drawing, or before a press when no path is selected that the Smooth tool can act on (with one selected, Smooth shows): releasing the mouse button with Alt still held closes the path.", "The Paintbrush tool while a stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.", + "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", ] hotspot = "The bristle tip." look = "The Paintbrush cursor with a ring badge (closing the loop), like PenClose." diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 65ad5f00e7..8ff1ac3d67 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -748,7 +748,7 @@ fn pencil_shows_the_straight_line_cursor_when_a_press_draws_a_line() { t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "a freehand stroke"); assert_eq!(GestureTool::new("paintbrush").cursor(&cx, at, shift), Cursor::Paintbrush); - assert_eq!(Cursor::PencilStraight.precise(), Cursor::Crosshair, "Use Precise Cursors"); + assert_eq!(Cursor::PencilStraight.precise(), Cursor::PencilStraight, "Use Precise Cursors keeps the badge"); } /// Before a press, the Pencil shows its new-path cursor (the star) exactly when the press draws @@ -883,7 +883,7 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let cx_none = crate::testutil::cx(&d, &none, &p); assert_eq!(GestureTool::new("pencil").cursor(&cx_none, end, shift), Cursor::PencilStraight, "nothing selected"); assert_eq!(GestureTool::new("paintbrush").cursor(&cx, end, shift), Cursor::Paintbrush); - assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); + assert_eq!(Cursor::PencilContinue.precise(), Cursor::PencilContinue, "Use Precise Cursors keeps the badge"); } /// A Pencil stroke continuing a selected path shows the continuation cursor while a release would diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 17e6f038e9..1a629d7ab8 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -660,7 +660,10 @@ mod cursor_tests { // Away from a selected path the Pencil shows its new-path cursor, drawn with a star; its // own cursor, for drawing and for a selected path under the pointer, borrows the crosshair. assert_eq!(cursor_at("pencil", &d, away), Cursor::PencilNew); - assert_eq!(Cursor::PencilNew.precise(), Cursor::Crosshair); + // Its badged cursors are drawn on the crosshair, and Use Precise Cursors keeps the badge. + for c in [Cursor::PencilNew, Cursor::PencilClose, Cursor::PencilStraight, Cursor::PencilContinue, Cursor::PaintbrushClose] { + assert_eq!(c.precise(), c, "{c:?}"); + } assert_eq!(Cursor::PencilNew.stand_in(), None); assert_eq!(Cursor::Pencil.precise(), Cursor::Crosshair); assert_eq!(Cursor::Pencil.stand_in(), Some(Cursor::Crosshair)); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a7adedb22d..8b97b1a88b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -341,7 +341,7 @@ tools, never checked side by side) isn't captured row by row. | Polygon/Star exact-size placement remembers the chosen sides/points for later drags and dialog openings; Cancel preserves the previous count | done | Session tool options; existing dialog-opening gestures retained; regression in `toolbar.rs` | | Tool double-click opens its options | done | | | Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | | -| Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | +| Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change; the Pencil's and the Paintbrush's cursors with a badge (new path, continue, close, straight line) are drawn on the crosshair and keep their badge (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | | macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. While it owns the cursor, the host sets it again on every pass, so the tool's cursor stays when the pointer moves (#6362). Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | | Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | From e7240b5a373753267b07eca20b5c2bc12dfdb3bd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:06:43 -0800 Subject: [PATCH 08/89] i18n: Hungarian has a row for 1,813 of the messages the untranslated report lists The Hungarian catalog lacked a row for 1,853 of the status and error messages that the untranslated report finds in the sources, so the status bar showed those messages in English. - hu.tsv gains 1,813 `@msg` rows: the errors of commands and their parameter checks, scripts, file import and export, printing and plug-ins. The translations are VectorCraft's own, written from the English meaning with the catalog's conventions and its existing feature names. Placeholders, parameter names, command ids and literal values stay as the English writes them. - The 38 rows that open #7211, #7116 and #7330 add to hu.tsv are left out, since a second row for the same key fails bundled_catalogs_are_consistent. The report lists those 38 and two messages that no catalog has a row for. Menu strings, `tl!` literals, undo labels and swatch names are unchanged. - Two of the messages have two placeholders in a row. The catalog does not load such a row, so those two are still shown in English. The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. - The Hungarian row of docs/localization-parity.md gives the catalog's current message counts. Docs: docs/target-app-parity.md, docs/localization-parity.md. --- crates/ui-egui/src/i18n/hu.tsv | 1814 ++++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 3 files changed, 1816 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ab06863782..e7c16017d1 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7475,3 +7475,1817 @@ breakText Glyphs Glifák Grommets Ringlik Grommet Inset Ringlik távolsága a széltől Diameter Átmérő +# Status and error messages of commands, scripts, file import and export, and plug-ins. +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions a 'caller', a 'callee' és az 'arguments' nem használható szigorú módú függvényeken +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Egy profilon egy Forgatás tengely körül effektus lehet. Inkább a meglévő effektus beállításait szerkeszd. +@msg Add Points at Crossings: a path went missing Pontok hozzáadása a kereszteződésekben: egy útvonal eltűnt +@msg Alignment must be one of the choices Az igazítás csak a választási lehetőségek egyike lehet +@msg Boolean.prototype.{name} was called on a value that is not a Boolean a Boolean.prototype.{name} hívása olyan értéken történt, amely nem Boolean +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB Azok a CMYK-képek, amelyek mintái nem voltak CMYK-ban olvashatók, RGB-ben lettek importálva +@msg CSS options: {e} CSS-beállítások: {e} +@msg CSV has a header but no data rows A CSV-ben van fejléc, de nincsenek adatsorok +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark A CSV NUL bájtokat tartalmaz: mentsd UTF-8-ként, vagy bájtsorrendjellel ellátott UTF-16-ként +@msg CSV is empty A CSV üres +@msg CSV is larger than {MAX_CSV_BYTES} bytes A CSV nagyobb, mint {MAX_CSV_BYTES} bájt +@msg CSV is not UTF-16 A CSV nem UTF-16 kódolású +@msg CSV is not UTF-8 A CSV nem UTF-8 kódolású +@msg CSV would be larger than {MAX_CSV_BYTES} bytes A CSV nagyobb lenne, mint {MAX_CSV_BYTES} bájt +@msg Cannot write to the location of “{name}”; choose where to save it Nem lehet írni ennek a helyére: „{name}”; válaszd ki, hová kerüljön a mentés +@msg Choose original artwork, not a dimension's generated paths Eredeti grafikát válassz, ne egy méretvonal generált útvonalait +@msg Contour: select paths or shapes Kontúr: jelölj ki útvonalakat vagy alakzatokat +@msg Contour: the Offset Path effect is not available Kontúr: az Útvonal eltolása effektus nem érhető el +@msg Create needs fills, strokes or both A létrehozáshoz kitöltések, körvonalak vagy mindkettő kell +@msg DCS files can only be placed linked: use File › Place with Link on A DCS-fájlok csak csatolva helyezhetők el: használd a Fájl › Elhelyezés parancsot bekapcsolt Csatolás beállítással +@msg DXF options: {e} DXF-beállítások: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} A DXF-lépték pozitív szám lehet, legfeljebb {_1}; ez nem az: {scale} +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Date.prototype method called on a value that is not a Date object Date.prototype metódus hívása olyan értéken történt, amely nem Date objektum +@msg Dimension is locked or hidden A méretvonal zárolt vagy rejtett +@msg Dimension mode does not match its source A méretvonal módja nem egyezik a forrásával +@msg Draw Inside needs exactly one selected path, compound path or text A Rajzolás belülre módhoz pontosan egy kijelölt útvonal, összetett útvonal vagy szöveg kell +@msg EPS Save writes all artwork: use document.export for selected-only EPS Az EPS-mentés a teljes grafikát kiírja: csak a kijelölést tartalmazó EPS-hez használd a document.export parancsot +@msg EPS flattener: {e} EPS-lapító: {e} +@msg EPS options: {e} EPS-beállítások: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Error.prototype.toString was called on a value that is not an object az Error.prototype.toString hívása olyan értéken történt, amely nem objektum +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Az Exportálás képernyőkre ezeket írja: png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai vagy vectorcraft +@msg Flare click coordinates must be finite numbers A Fényvillanás kattintási koordinátái véges számok legyenek +@msg Function.prototype.toString was called on a value that is not a function a Function.prototype.toString hívása olyan értéken történt, amely nem függvény +@msg HTML Text applies to object slices of type only A HTML-szöveg csak szövegobjektumból készült szeletekre alkalmazható +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Az Objektumok azonosítása beállítás XML-azonosító: a névnek érvényes XML-azonosítónak kell lennie (betűvel, aláhúzásjellel vagy kettősponttal kezdődik; szóköz nélkül) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) A képvektorizálás nem sikerült: belső hiba: a szála leállt (kérjük, jelentsd ezt a hibát) +@msg Image Trace failed: {e} A képvektorizálás nem sikerült: {e} +@msg Image Trace finished, but its document was closed A képvektorizálás elkészült, de a dokumentumát bezárták +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again A képvektorizálás elkészült, de a kép vagy a beállításai közben megváltoztak: vektorizáld újra +@msg Import SVG Filter reads SVG files, not SVGZ Az SVG-szűrő importálása SVG-fájlokat olvas, SVGZ-fájlokat nem +@msg Invalid dimension offset Érvénytelen méretvonal-eltolás +@msg Invalid point Érvénytelen pont +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (eltolás: {at}) +@msg JSON.stringify cannot write a structure that contains itself a JSON.stringify nem tud olyan szerkezetet kiírni, amely önmagát tartalmazza +@msg Join: a subpath went missing Összekapcsolás: egy részútvonal eltűnt +@msg Line Gap must be a number A Sorköz szám kell legyen +@msg Lock Workspace is not available in VectorCraft A Munkaterület zárolása nem érhető el a VectorCraftban +@msg Move Panel to New Window is not available: floating panels stay in the main window A Panel áthelyezése új ablakba nem érhető el: a lebegő panelek a főablakban maradnak +@msg Namespace.prototype.toString needs a Namespace a Namespace.prototype.toString hívásához Namespace kell +@msg New Document profiles need the preferences folder, which this run doesn't have Az új dokumentumok profiljaihoz a beállítások mappája kell, amely ebben a futásban nincs meg +@msg Number.prototype.{name} was called on a value that is not a number a Number.prototype.{name} hívása olyan értéken történt, amely nem szám +@msg Object.create's prototype must be an object or null az Object.create prototípusa objektum vagy null lehet +@msg Object.{function} was given a value that is not an object az Object.{function} olyan értéket kapott, amely nem objektum +@msg Offset Path: the path is too complex to offset Útvonal eltolása: az útvonal túl összetett az eltoláshoz +@msg Offset Path: {e} Útvonal eltolása: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported A PDF tartalma hiányos, vagy túllépi az olvasó korlátait; nem lett dokumentum importálva +@msg PDF flattener: {e} PDF-lapító: {e} +@msg PDF options: {e} PDF-beállítások: {e} +@msg QName.prototype.toString needs a QName a QName.prototype.toString hívásához QName kell +@msg Recolor Artwork is no longer open A Grafika átszínezése már nincs megnyitva +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object a RegExp.prototype.{name} hívása olyan értéken történt, amely nem RegExp objektum +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 Az SVG nem UTF-8, UTF-16 vagy ISO 8859-1 kódolású +@msg Save writes one artboard: name one, or export several with document.export A mentés egy rajztáblát ír ki: nevezz meg egyet, vagy exportálj többet a document.export paranccsal +@msg Select a dimension first Előbb jelölj ki egy méretvonalat +@msg Select dimensions to update Jelöld ki a frissítendő méretvonalakat +@msg Select only dimensions Csak méretvonalakat jelölj ki +@msg Select two or more objects, or a group Jelölj ki két vagy több objektumot, vagy egy csoportot +@msg Shaper needs 1–64 source paths Az Alakformálóhoz 1–64 forrásútvonal kell +@msg Stencil Bridges: the selected art is too large or too detailed to bridge Sablonhidak: a kijelölt grafika túl nagy vagy túl részletes a hidak elhelyezéséhez +@msg String.prototype.toString was called on a value that is not a string a String.prototype.toString hívása olyan értéken történt, amely nem karakterlánc +@msg Switch x/y is for scatter graphs Az x/y felcserélése szóródiagramokhoz való +@msg Text options: {e} Szövegbeállítások: {e} +@msg The {name} profile could not be read, so the new document has the default settings: {reason} Ez a profil nem volt olvasható: {name}; ezért az új dokumentum az alapértelmezett beállításokat kapta: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time Csapdázás: több mint {_1} objektumpár van elég közel a csapdázáshoz, vagy túl sok objektum esik azonos vízszintes tartományba; egyszerre kevesebb objektumot csapdázz +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Gyomláló keret és vonalak: a grafikán kívül nem fér el gyomlálóvonal, és a gyomláló keret ki van kapcsolva +@msg \\ at end of pattern \\ a minta végén +@msg `alignOn` cannot be empty `alignOn`: nem lehet üres +@msg `alignOn` must be a string `alignOn`: karakterlánc kell +@msg `align` is start, center or end `align`: start, center vagy end +@msg `align` must be a string `align`: karakterlánc kell +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors`: [subpath, anchor] párok tömbje kell +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {k} +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {v} +@msg `answer` for this {kind} dialog must be one of: {_1} `answer`: ennél a párbeszédablaknál ({kind}) ezek egyike kell: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers`: több mint {MAX_ANSWERS} választ tartalmaz +@msg `answers` is larger than 1 MiB `answers`: nagyobb 1 MiB-nál +@msg `answers` must be a list of answers `answers`: válaszok listája kell +@msg `artboard` must be the index of an artboard of the document `artboard`: a dokumentum egyik rajztáblájának indexe kell +@msg `artboardsAt` must be [x, y] `artboardsAt`: [x, y] kell +@msg `artboards` must be a list of artboard indices `artboards`: rajztáblaindexek listája kell +@msg `artboards` names an artboard the document does not have `artboards`: olyan rajztáblát nevez meg, amely nincs a dokumentumban +@msg `artboards` names an artboard twice `artboards`: egy rajztáblát kétszer nevez meg +@msg `asset` is the asset id (see assets.list) `asset`: az elem azonosítója (lásd: assets.list) +@msg `at` must be [x, y] (got {at}) `at`: [x, y] kell (kapott érték: {at}) +@msg `at` must be a byte offset `at`: bájteltolás kell +@msg `at` must be a point [x, y] `at`: [x, y] pont kell +@msg `autoLeading` must be a number `autoLeading`: szám kell +@msg `background` must be "", "matte" or #rrggbb `background`: "", "matte" vagy #rrggbb kell +@msg `beside` needs `side` `beside`: `side` is kell hozzá +@msg `burasagari` must be "none", "standard" or "forced" `burasagari`: "none", "standard" vagy "forced" kell +@msg `center` must be [x, y] `center`: [x, y] kell +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign`: "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" vagy "icfBottom" kell +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection`: "default", "leftToRight" vagy "rightToLeft" kell +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke`: `stroke: false` és `ids: []` kell hozzá, `stop` nélkül +@msg `clear` must be a list of type object ids, not {v} `clear`: szövegobjektum-azonosítók listája kell, ez nem az: {v} +@msg `clear` must list type objects; {_1} is not one `clear`: szövegobjektumok listája kell; ez nem az: {_1} +@msg `clear` must not list `id` `clear`: nem szerepelhet benne az `id` +@msg `code` must be a whole number `code`: egész szám kell +@msg `colorMode` must be one of rgb, cmyk `colorMode`: ezek egyike kell: rgb, cmyk +@msg `color` needs a `rule` to make the theme from `color`: `rule` is kell hozzá, amelyből a téma készül +@msg `colors` does not apply to the {name} reduction `colors`: ennél a színcsökkentésnél nem használható: {name} +@msg `colors` is empty `colors`: üres +@msg `colors` must be a whole number from 2 to 256 `colors`: 2 és 256 közötti egész szám kell +@msg `colors` must be at least 9 for the uniform reduction `colors`: az egyenletes színcsökkentéshez legalább 9 kell +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors`: 1 és {MAX_THEME_COLORS} közötti érték kell (kapott érték: {n}) +@msg `columnType` is vertical, uniform, repeating or sliding `columnType`: vertical, uniform, repeating vagy sliding +@msg `columns` must be a whole number 1..50 `columns`: egész szám kell, 1..50 +@msg `commands` must not be empty `commands`: nem lehet üres +@msg `contents` goes with `to` `contents`: a `to` kulccsal együtt használható +@msg `corners` must be sharp, round or square, not `{other}` `corners`: sharp, round vagy square kell, ez nem az: `{other}` +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {v} +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count`: 0 és {MAX_COUNT} közötti egész szám kell +@msg `count` must be a whole number from 1 to 100 `count`: 1 és 100 közötti egész szám kell +@msg `dataBase64` is larger than the module limit `dataBase64`: nagyobb a modulok méretkorlátjánál +@msg `dataBase64` is not valid base64 `dataBase64`: nem érvényes base64 +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly`: nem használható együtt ezekkel: `ids`, `reverse`, `append` +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` és `childLimit`: az összegzést szűkítik; add meg ezt: `summary: true` +@msg `design` is a design name, or null for plain columns `design`: diagramminta neve, vagy null az egyszerű oszlopokhoz +@msg `design` is a design name, or null for the default marker `design`: diagramminta neve, vagy null az alapértelmezett jelölőhöz +@msg `destinationFolder` is required when destination is `folder` `destinationFolder`: kötelező, ha a cél `folder` +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination`: `none`, `saveAndClose` vagy `folder` kell +@msg `diameter` must be from 0.1 to 2500 pt `diameter`: 0.1 és 2500 pt közötti érték kell +@msg `digits` must be one of {_1} `digits`: ezek egyike kell: {_1} +@msg `dimImages` is a percentage or false `dimImages`: százalék vagy false +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction`: "auto", "leftToRight" vagy "rightToLeft" kell +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount`: 0 és 100 közötti szám kell +@msg `each` is the value one design stands for, more than 0 `each`: az az érték, amelyet egy minta jelöl; 0-nál nagyobb +@msg `end` must be "start" or "end" `end`: "start" vagy "end" kell +@msg `ends` applies to joining exactly two paths `ends`: pontosan két útvonal összekapcsolásakor használható +@msg `event` must be one of {_1} `event`: ezek egyike kell: {_1} +@msg `every` must be "all", "odd" or "even", not {v} `every`: "all", "odd" vagy "even" kell, ez nem az: {v} +@msg `expand` must be true or false `expand`: true vagy false kell +@msg `fidelity` must be from {lo} to {hi} pt `fidelity`: {lo} és {hi} pt közötti érték kell +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files`: a legutóbbi text.findFontFiles keresés által talált betűtípusfájlok 1–1000 elérési útja kell +@msg `focal` and `radius` cannot be used together `focal` és `radius`: nem használhatók együtt +@msg `focal` must be an array of [subpath, anchor] pairs `focal`: [subpath, anchor] párok tömbje kell +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal`: ezt nevezi meg: [{si}, {ai}], de ez nem az útvonal horgonypontja +@msg `folder` must be an absolute folder path `folder`: abszolút mappaútvonal kell +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes`: tengelycímkék és értékek objektuma kell, például {"wght"} +@msg `format` must be a string `format`: karakterlánc kell +@msg `format` must be one of {_1} `format`: ezek egyike kell: {_1} +@msg `fractions` is chop or scale `fractions`: chop vagy scale +@msg `from` and `to` must differ `from` és `to`: különbözniük kell +@msg `from` has an empty frame `from`: üres a kerete +@msg `from` must be a non-negative integer `from`: nemnegatív egész szám kell +@msg `from` must be area type or type on a path `from`: területszöveg vagy útvonalszöveg kell +@msg `gid` must be a glyph id, not {v} `gid`: glifaazonosító kell, ez nem az: {v} +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate`: egyetlen karakterhez tartozik, nem karakterstílushoz (a text.setGlyphAlternate állítja be) +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate`: csak null adható meg, amely megszünteti a glifaválasztást (a text.setGlyphAlternate hoz létre ilyet) +@msg `group` must be one of {_1} `group`: ezek egyike kell: {_1} +@msg `group` must be the Objects on Path group of `member` and `target` `group`: a `member` és a `target` Objektumok útvonalon csoportja kell +@msg `guide` must be a guide index (guide.list) `guide`: segédvonalindex kell (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign`: default, left, center vagy right kell +@msg `id` is an object id, not {v} `id`: objektumazonosító, ez nem az: {v} +@msg `id` must be a node id `id`: csomópont-azonosító kell +@msg `id` must be an object id, not {v} `id`: objektumazonosító kell, ez nem az: {v} +@msg `ids` must name Objects on Path groups or objects in them `ids`: Objektumok útvonalon csoportokat vagy a bennük lévő objektumokat kell megneveznie +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids`: legfeljebb {MAX_INSPECT_IDS} azonosítót fogad +@msg `imageMap` must be none, rectangle or polygon `imageMap`: none, rectangle vagy polygon kell +@msg `includeArtboardFill` must be true or false `includeArtboardFill`: true vagy false kell +@msg `includeSubdirs` must be true or false `includeSubdirs`: true vagy false kell +@msg `index` must be a point index `index`: pontindex kell +@msg `index` must be a whole number or null `index`: egész szám vagy null kell +@msg `indices` must be a non-empty array of item indices `indices`: elemindexek nem üres tömbje kell +@msg `item` needs `index` `item`: `index` is kell hozzá +@msg `items` is a list of {kind, item} `items`: {kind, item} elemek listája +@msg `items` is empty `items`: üres +@msg `items` must be a list of {id, points} `items`: {id, points} elemek listája kell +@msg `items` must be a list of {{id, anchors?}}, not {v} `items`: {{id, anchors?}} elemek listája kell, ez nem az: {v} +@msg `items` must be an array of {id, anchors} `items`: {id, anchors} elemek tömbje kell +@msg `items` must be an array of {{id, corners?}}, not {v} `items`: {{id, corners?}} elemek tömbje kell, ez nem az: {v} +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida`: "none", "short", "medium", "long" vagy "stylistic" kell +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore`: a sor első karakteréhez tartozik, nem karakterstílushoz (a text.setRangeStyle állítja be) +@msg `kind` is one of {_1} `kind`: ezek egyike: {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind`: "round", "invertedRound" vagy "chamfer" kell, ez nem az: {v} +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind`: `text`, `visibility`, `image` vagy `graph` kell +@msg `kind` must be a string `kind`: karakterlánc kell +@msg `kind` must be image, noImage or htmlText `kind`: image, noImage vagy htmlText kell +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType`: "pushInFirst", "pushOutFirst" vagy "pushOutOnly" kell +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku`: "none", "hard" vagy "soft" kell +@msg `label` must be a non-empty name of at most 200 characters `label`: nem üres, legfeljebb 200 karakteres név kell +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language`: nyelvnév vagy nyelvcímke kell, például "Turkish" vagy "tr" +@msg `leader` must be a string `leader`: karakterlánc kell +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel`: "romanBaseline" vagy "emBoxTop" kell +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading`: 0.1 és 5000 közötti érték kell, vagy null az automatikushoz +@msg `length` must be a number of at most 1000000 pt either way `length`: szám kell, bármelyik irányban legfeljebb 1000000 pt +@msg `line` must be true or false `line`: true vagy false kell +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max`: 1 és {MAX_SUGGESTIONS} közötti érték kell +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°): nem lehet nagyobb ennél: `maxAngle` ({max_angle}°) +@msg `mode` must be "handles", "straight" or "smooth" `mode`: "handles", "straight" vagy "smooth" kell +@msg `mode` must be "straight", "bezier" or "arc" `mode`: "straight", "bezier" vagy "arc" kell +@msg `mode` must be one of {_1} `mode`: ezek egyike kell: {_1} +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi`: "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" vagy "lineEndHalf" kell +@msg `name` must be a name that is not empty `name`: nem üres név kell +@msg `name` must not be empty `name`: nem lehet üres +@msg `names` is a list of preset names `names`: előbeállítás-nevek listája +@msg `names` lists preset names `names`: előbeállítás-neveket sorol fel +@msg `names` must be a list of names `names`: nevek listája kell +@msg `names` must be a list of swatch names `names`: színmintanevek listája kell +@msg `names` must be an array of style names `names`: stílusnevek tömbje kell +@msg `new` is a design name, or true `new`: diagramminta neve vagy true +@msg `origin` must be [x, y] in points `origin`: [x, y] kell pontban megadva +@msg `path` must be a folder `path`: mappa kell +@msg `path` must be a path object `path`: útvonalobjektum kell +@msg `picks` must be an array (got {v}) `picks`: tömb kell (kapott érték: {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks`: 1 és {MAX_PICKS} közötti számú választást kell tartalmaznia (kapott érték: {_1}) +@msg `pivot` must be a whole number from 0 to 8 `pivot`: 0 és 8 közötti egész szám kell +@msg `points` must be a list of [x, y] points `points`: [x, y] pontok listája kell +@msg `points` must be a list of point indexes `points`: pontindexek listája kell +@msg `points` must be an array of point indices `points`: pontindexek tömbje kell +@msg `points` must be point indexes `points`: pontindexek kellenek +@msg `points` must hold from 2 to 100000 points `points`: 2 és 100000 közötti számú pontot kell tartalmaznia +@msg `port` must be "in" or "out" `port`: "in" vagy "out" kell +@msg `position` is above or below `position`: above vagy below +@msg `preset` must be a string `preset`: karakterlánc kell +@msg `radius` must be 0 or more `radius`: 0 vagy nagyobb érték kell +@msg `ratio` must be from 0.1 to 0.5 `ratio`: 0.1 és 0.5 közötti érték kell +@msg `rect` must be [x, y, width, height] `rect`: [x, y, width, height] kell +@msg `rect` must be [x, y, width, height] with a positive size `rect`: [x, y, width, height] kell pozitív mérettel +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect`: [x0, y0, x1, y1] kell (kapott érték: {r}) +@msg `relation` must be overlap, inside or outside `relation`: overlap, inside vagy outside kell +@msg `replace` must be a string `replace`: karakterlánc kell +@msg `reversed` must be true or false `reversed`: true vagy false kell +@msg `rotate` must be a finite number `rotate`: véges szám kell +@msg `rows` must be lists of numbers or nulls, not {r} `rows`: számok vagy null értékek listái kellenek, ez nem az: {r} +@msg `rule` must be nonZero or evenOdd `rule`: nonZero vagy evenOdd kell +@msg `scale` must be a number from 0.001 to 1000 `scale`: 0.001 és 1000 közötti szám kell +@msg `scale` must be a number in (0, 100] `scale`: a (0, 100] tartományba eső szám kell +@msg `scope.{key}` must be a non-empty array `scope.{key}`: nem üres tömb kell +@msg `scope` must be "document", {artboards} or {layers} `scope`: "document", {artboards} vagy {layers} kell +@msg `scope` must be "selection" or "document", not {other} `scope`: "selection" vagy "document" kell, ez nem az: {other} +@msg `script` must be a string `script`: karakterlánc kell +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes`: adatsorindexek nem üres listája +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes`: `type` vagy `valueAxis` is kell hozzá +@msg `shift` must be a number of points `shift`: pontban megadott szám kell +@msg `showCenter` must be true or false `showCenter`: true vagy false kell +@msg `side` must be "left", "right", "top" or "bottom" `side`: "left", "right", "top" vagy "bottom" kell +@msg `side` places the artboard: leave out x, y, width and height `side`: ez helyezi el a rajztáblát; hagyd el az x, y, width és height értéket +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification`: "fullJustify", "alignLeft", "alignCenter" vagy "alignRight" kell +@msg `size` is a percentage of the base text's size, 1 to 100 `size`: az alapszöveg méretének százaléka, 1 és 100 között +@msg `source` and `target` are the same object `source` és `target`: ugyanaz az objektum +@msg `source` is the only path in series group `target` `source`: az egyetlen útvonal ebben a sorozatcsoportban: `target` +@msg `source` must be `folder` or `datasets` `source`: `folder` vagy `datasets` kell +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing`: ezzel több mint {MAX_COUNT} vonal jutna a keretre +@msg `spacing` must be a number `spacing`: szám kell +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing`: pontban megadott, 0 vagy nagyobb szám, vagy "auto" kell +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing`: 0-nál nagyobb és legfeljebb {MAX_DISTANCE} pt érték kell +@msg `start` must be a finite number `start`: véges szám kell +@msg `starts` must be an array of anchor indices (or nulls) `starts`: horgonypont-indexek (vagy null értékek) tömbje kell +@msg `status` must be linked, embedded, missing or modified, or a list of them `status`: linked, embedded, missing vagy modified kell, vagy ezek listája +@msg `stop` must be a stop index `stop`: színátmenetpont-index kell +@msg `story` styles the whole story: give no `start` or `end` `story`: a teljes szövegfolyamot formázza; ne adj meg `start` vagy `end` értéket +@msg `story` takes the whole story: give no `start` or `end` `story`: a teljes szövegfolyamra vonatkozik; ne adj meg `start` vagy `end` értéket +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode`: körvonalakra vonatkozik (stroke: true) +@msg `style` must be roman or japanese `style`: roman vagy japanese kell +@msg `subpaths` must be two integer indices `subpaths`: két egész indexet kell megadni +@msg `symbol` must be a symbol name `symbol`: szimbólumnév kell +@msg `target` must be "object" or "contents", not {v} `target`: "object" vagy "contents" kell, ez nem az: {v} +@msg `target` must be an object of the same Objects on Path group as `member` `target`: ugyanannak az Objektumok útvonalon csoportnak egy objektuma kell, mint a `member` +@msg `tint` applies to global and spot colours `tint`: globális színekre és direktszínekre vonatkozik +@msg `tint` goes with a global or spot `swatch` `tint`: globális vagy direktszínű `swatch` értékkel együtt használható +@msg `tint` must be a number (%) `tint`: szám kell (%) +@msg `tint` needs a global or spot colour `tint`: globális szín vagy direktszín kell hozzá +@msg `to` is already threaded `to`: már láncolva van +@msg `to` is the design's new name `to`: a diagramminta új neve +@msg `to` must be [x, y] `to`: [x, y] kell +@msg `to` must be a closed path, area type or type on a path other than `from` `to`: zárt útvonal, területszöveg vagy útvonalszöveg kell, amely nem azonos ezzel: `from` +@msg `to` must be a whole number `to`: egész szám kell +@msg `to` takes the rows of one object's stack, not aligned rows `to`: egyetlen objektum halmának sorait fogadja, nem igazított sorokat +@msg `tolerance` must be from 0 to 100 pt `tolerance`: 0 és 100 pt közötti érték kell +@msg `useLab` must be true or false `useLab`: true vagy false kell +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign`: default, top, middle, baseline vagy bottom kell +@msg `values` must be a list `values`: lista kell +@msg `values` must be an object `values`: objektum kell +@msg `values` must name hex, rgb, cmyk, lab or gray `values`: ezek közül kell megneveznie: hex, rgb, cmyk, lab vagy gray +@msg `values` names a kind twice `values`: egy fajtát kétszer nevez meg +@msg `version` must be 0 or {_1} `version`: 0 vagy {_1} kell +@msg `version` must be 0 or {newest} `version`: 0 vagy {newest} kell +@msg `warichuAlign` must be one of {_1} `warichuAlign`: ezek egyike kell: {_1} +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines`: {_1} és {_2} közötti egész szám kell +@msg `weight` must be from 0.01 to 1000 pt `weight`: 0.01 és 1000 pt közötti érték kell +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights`: {_1} számból álló lista kell, soronként egy (kapott érték: {v}) +@msg `xml` must be a string `xml`: karakterlánc kell +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}`, {n}. bejegyzés: más behúzásokat ad a(z) {_1} objektum {start}. bájtjánál kezdődő bekezdésnek, mint egy korábbi bejegyzés +@msg `{KEY}` entry {n} must be an object `{KEY}`, {n}. bejegyzés: objektum kell +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}`, {n}. bejegyzés: sem a `leftIndent`, sem a `firstLineIndent` értéket nem állítja be +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}`, {n}. bejegyzés: `id`: objektumazonosító kell +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}`, {n}. bejegyzés: `start`: bájteltolás kell +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}`, {n}. bejegyzés: `{k}`: -{MAX_PARA} és {MAX_PARA} pt közötti szám kell +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}`, {n}. bejegyzés: a(z) {_1} objektumnak nincs a(z) {start}. bájtnál kezdődő bekezdése +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}`, {n}. bejegyzés: ismeretlen kulcs: `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}`, {n}. bejegyzés: nem szövegobjektum: {_1} +@msg `{KEY}` is empty `{KEY}`: üres +@msg `{KEY}` must be a list `{KEY}`: lista kell +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}`: legfeljebb {MAX_PARAGRAPH_INDENTS} bejegyzést fogad (kapott érték: {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}`: mellette nem adható meg ez: `{other}` +@msg `{_1}` can't be edited or removed nem szerkeszthető és nem távolítható el: `{_1}` +@msg `{_1}` is built in and always listed beépített elem, mindig szerepel a listán: `{_1}` +@msg `{_1}` is not a font file the last text.findFontFiles search found nem a legutóbbi text.findFontFiles keresés által talált betűtípusfájl: `{_1}` +@msg `{_1}` would be overwritten by the package: choose a different folder or name a csomag felülírná ezt: `{_1}`; válassz másik mappát vagy nevet +@msg `{base}` is larger than {_1} MB nagyobb, mint {_1} MB: `{base}` +@msg `{base}` is not in the DCS file's folder nincs a DCS-fájl mappájában: `{base}` +@msg `{file}` has no art to place nincs benne elhelyezhető grafika: `{file}` +@msg `{file}` is over {_1} MB nagyobb, mint {_1} MB: `{file}` +@msg `{f}` belongs to a VectorCraft that is running egy futó VectorCrafthoz tartozik: `{f}` +@msg `{f}` is the copy of a document open now egy most megnyitott dokumentum másolata: `{f}` +@msg `{id}` is no writable format (see document.formats) nem írható formátum: `{id}` (lásd: document.formats) +@msg `{id}` isn't a loaded library nem betöltött könyvtár: `{id}` +@msg `{key}` has no colours `{key}`: nincsenek benne színek +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}`: több mint {MAX_EDIT_WORDS} szót tartalmaz +@msg `{key}` is a design name `{key}`: diagramminta neve +@msg `{key}` is a design name or a list of them `{key}`: diagramminta neve vagy ilyen nevek listája +@msg `{key}` is longer than {MAX_NAME} characters `{key}`: hosszabb {MAX_NAME} karakternél +@msg `{key}` is out of range `{key}`: tartományon kívül esik +@msg `{key}` must be 0 or more `{key}`: 0 vagy nagyobb érték kell +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}`: [min, desired, max] vagy {{min?, desired?, max?}} kell, százalékban +@msg `{key}` must be [x, y] numbers `{key}`: [x, y] számpár kell +@msg `{key}` must be a list of words `{key}`: szavak listája kell +@msg `{key}` must be a list, not {v} `{key}`: lista kell, ez nem az: {v} +@msg `{key}` must be a non-negative integer `{key}`: nemnegatív egész szám kell +@msg `{key}` must be a number or null, not {v} `{key}`: szám vagy null kell, ez nem az: {v} +@msg `{key}` must be a number {_1}..{_2} `{key}`: szám kell, {_1}..{_2} +@msg `{key}` must be a whole number of 0 or more `{key}`: 0 vagy nagyobb egész szám kell +@msg `{key}` must be a whole number of at least 0, not {n} `{key}`: legalább 0 értékű egész szám kell, ez nem az: {n} +@msg `{key}` must be an appearance item index (paint order) or null `{key}`: megjelenési elem indexe (festési sorrend) vagy null kell +@msg `{key}` must be an array of asset ids (see assets.list) `{key}`: elemazonosítók tömbje kell (lásd: assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}`: 0 és 180 fok közötti érték kell, ez nem az: {a} +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}`: 0 és {MAX_DISTANCE} pt közötti érték kell +@msg `{key}` must be from {_1} to {shift_max} `{key}`: {_1} és {shift_max} közötti érték kell +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}`: {lo} és {hi} közötti érték kell, ez nem az: {v} +@msg `{key}` must be one of auto, always, never `{key}`: ezek egyike kell: auto, always, never +@msg `{key}` must be one of miter, round, bevel `{key}`: ezek egyike kell: miter, round, bevel +@msg `{key}` must be one word of at most {_1} characters `{key}`: egyetlen, legfeljebb {_1} karakteres szó kell +@msg `{key}` must hold single words of at most {_1} characters `{key}`: különálló, legfeljebb {_1} karakteres szavakat kell tartalmaznia +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}`: egy Objektumok útvonalon csoport útvonalához csatolt objektumot kell megneveznie +@msg `{key}` names an object that is hidden or locked `{key}`: rejtett vagy zárolt objektumot nevez meg +@msg `{key}`: not a colour: {v} `{key}`: nem szín: {v} +@msg `{k}.family` is required `{k}.family`: kötelező +@msg `{k}` is a number 0..1 `{k}`: szám, 0..1 +@msg `{k}` is true or false `{k}`: true vagy false +@msg `{k}` isn't an export setting: {_1} `{k}`: nem exportálási beállítás: {_1} +@msg `{k}` must be a byte offset `{k}`: bájteltolás kell +@msg `{k}` must be a number `{k}`: szám kell +@msg `{k}` must be a number of points `{k}`: pontban megadott szám kell +@msg `{k}` must be a string `{k}`: karakterlánc kell +@msg `{k}` must be a whole number `{k}`: egész szám kell +@msg `{k}` must be from {lo} to {hi} for {key} `{k}`: {lo} és {hi} közötti érték kell ehhez: {key} +@msg `{k}` must be {what}, not {v} `{k}`: ez kell: {what}; ez nem az: {v} +@msg `{l}` isn't a global or spot colour nem globális szín és nem direktszín: `{l}` +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText nem bitkép: `{m}`; használd a clipboard.importSvg, az importPdf vagy az importText parancsot +@msg `{name}` can't be deleted nem törölhető: `{name}` +@msg `{name}` can't be duplicated nem kettőzhető: `{name}` +@msg `{name}` can't be edited nem szerkeszthető: `{name}` +@msg `{name}` can't be edited this way így nem szerkeszthető: `{name}` +@msg `{name}` can't be renamed nem nevezhető át: `{name}` +@msg `{name}` can't name a folder nem nevezhet meg mappát: `{name}` +@msg `{name}` has no art to place nincs benne elhelyezhető grafika: `{name}` +@msg `{name}` has no text to place nincs benne elhelyezhető szöveg: `{name}` +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text a Word-dokumentumban lévő `{name}` nem UTF-8 vagy UTF-16 szöveg +@msg `{name}` in the Word document is not well-formed XML: {e} a Word-dokumentumban lévő `{name}` nem jól formált XML: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep a Word-dokumentumban lévő `{name}` elemei {MAX_NESTING} szintnél mélyebben vannak egymásba ágyazva +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` beépített előbeállítás, csak olvasható: mentsd más néven +@msg `{name}` is a built-in preset: save under another name `{name}` beépített előbeállítás: mentsd más néven +@msg `{name}` is a built-in profile and stays `{name}` beépített profil, megmarad +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` külön fájl, amely csak a DCS-fájl elérési útja mellett található meg +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` ennek a tónusa: `{base}`; ezt szerkeszd: `{base}` (egy tónusszínminta csak új nevet kaphat) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` munkaszíntér: előbb válassz másikat az edit.colorSettings paranccsal +@msg `{name}` is built in and stays `{name}` beépített elem, megmarad +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` csak a saját fajtájának megfelelő festést fogad (szín, színátmenet vagy mintázat) +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{n}` can't be deleted nem törölhető: `{n}` +@msg `{n}` can't be merged nem egyesíthető: `{n}` +@msg `{n}` can't be moved nem helyezhető át: `{n}` +@msg `{n}` is not a colour swatch nem színes színminta: `{n}` +@msg `{n}` isn't a solid colour nem egyszínű szín: `{n}` +@msg `{n}`: colour groups hold solid colours only `{n}`: a színcsoportok csak egyszínű színeket tartalmazhatnak +@msg `{other}` doesn't trace an image nem vektorizál képet: `{other}` +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: a lépés hivatkozásai több mint {_1} MB értéket másolnak be +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" nem tengelycímke: `{tag}`; a címke négy karakterből áll, például "wght" +@msg `{to}` is empty or already used üres vagy már használatban van: `{to}` +@msg `{u}` is no Unicode character nem Unicode-karakter: `{u}` +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}`: hosszabb {MAX_TEXT} bájtnál +@msg `{what}` must be text `{what}`: szöveg kell +@msg `{word}` isn't in the custom dictionary nincs az egyéni szótárban: `{word}` +@msg a CMap has too many mappings egy CMap túl sok leképezést tartalmaz +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard egy DXF-rajznak egy origója van: add meg a useArtboards: true értéket, ha rajztáblánként egy rajz kell +@msg a PDF presets file with over {_1} presets olyan PDF-előbeállításfájl, amelyben több mint {_1} előbeállítás van +@msg a \\u escape needs four hexadecimal digits a \\u escape-sorozathoz négy hexadecimális számjegy kell +@msg a batch preview cannot switch documents a kötegelt előnézet nem válthat dokumentumot +@msg a brush named `{nn}` exists már van ilyen nevű ecset: `{nn}` +@msg a comma in an XPath expression must separate function arguments XPath-kifejezésben a vessző csak függvényargumentumokat választhat el +@msg a container of the listed objects is gone a felsorolt objektumok egyik tárolója már nincs meg +@msg a content stream can't be read egy tartalomfolyam nem olvasható +@msg a content stream isn't a stream egy tartalomfolyam nem adatfolyam +@msg a design holds at most {_1} objects egy diagramminta legfeljebb {_1} objektumot tartalmazhat +@msg a design needs a name a diagrammintának név kell +@msg a dictionary grew too large egy szótár túl nagyra nőtt +@msg a document holds at most {_1} SVG filters egy dokumentum legfeljebb {_1} SVG-szűrőt tartalmazhat +@msg a document keeps at most {_1} graph designs egy dokumentum legfeljebb {_1} diagrammintát tárol +@msg a document keeps at most {_1} saved selections egy dokumentum legfeljebb {_1} mentett kijelölést tárol +@msg a document keeps at most {_1} views egy dokumentum legfeljebb {_1} nézetet tárol +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files egy dokumentum legfeljebb {MAX_SVG_SCRIPTS} JavaScript-fájlt csatolhat +@msg a document with unsaved changes has no recovery copy a mentetlen módosításokat tartalmazó dokumentumnak nincs helyreállítási másolata +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes egy feConvolveMatrix szűrő rendje {x}×{y}, ami túlcsordítja az usvg kernelméret-szorzását +@msg a font's data is too large egy betűtípus adatai túl nagyok +@msg a font's subroutines are too large egy betűtípus szubrutinjai túl nagyok +@msg a function declaration without a name név nélküli függvénydeklaráció +@msg a gradient preset is already called {n} már van ilyen nevű színátmenet-előbeállítás: {n} +@msg a graph has at most {_1} series, so it transposes with at most that many categories egy diagramnak legfeljebb {_1} adatsora lehet, ezért legfeljebb ennyi kategóriával cserélhető fel a sor és az oszlop +@msg a host value has too many entries egy gazdaérték túl sok elemet tartalmaz +@msg a host value is nested too deep egy gazdaérték túl mélyen van egymásba ágyazva +@msg a layer can't be merged into its own sublayer egy réteg nem egyesíthető a saját alrétegébe +@msg a layer's form box isn't an array egy réteg formaobjektumának kerete nem tömb +@msg a layer's form can't be read egy réteg formaobjektuma nem olvasható +@msg a layer's form has no box egy réteg formaobjektumának nincs kerete +@msg a layer's form has no dictionary egy réteg formaobjektumának nincs szótára +@msg a legend draws at most {MAX_CHIPS} swatches a jelmagyarázat legfeljebb {MAX_CHIPS} színmintát rajzol ki +@msg a name names one asset: give multiple: false, or one object egy név egy elemet nevez meg: adj meg multiple: false értéket, vagy egyetlen objektumot +@msg a namespace declaration uses a reserved prefix or URI egy névtér-deklaráció foglalt előtagot vagy URI-t használ +@msg a namespace qualifier is undefined egy névtérminősítő nincs definiálva +@msg a namespace with an empty URI cannot have a prefix üres URI-jú névtérnek nem lehet előtagja +@msg a number has no digits egy számban nincsenek számjegyek +@msg a number's exponent has no digits egy szám kitevőjében nincsenek számjegyek +@msg a number's fraction has no digits egy szám törtrészében nincsenek számjegyek +@msg a page can't be read egy oldal nem olvasható +@msg a page object is used by something else egy oldalobjektumot valami más is használ +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) az oldalbélyegkép RGB-képpontokból álljon, oldalanként 1 és {THUMBNAIL_SIZE} között (kapott méret: {w} × {h}) +@msg a pattern layer doesn't end what it starts egy mintázatréteg nem zárja le, amit megkezd +@msg a pattern layer's colors hold something else egy mintázatréteg színei mást tartalmaznak +@msg a pattern named `{n}` exists már van ilyen nevű mintázat: `{n}` +@msg a plug-in with id {_1} is already installed már telepítve van ilyen azonosítójú bővítmény: {_1} +@msg a preset named `{n}` exists már van ilyen nevű előbeállítás: `{n}` +@msg a print presets file with over {_1} presets olyan nyomtatási előbeállításfájl, amelyben több mint {_1} előbeállítás van +@msg a profile is already called {n} már van ilyen nevű profil: {n} +@msg a property descriptor cannot have both a value or writable and a get or set egy tulajdonságleíróban nem lehet egyszerre value vagy writable és get vagy set +@msg a property descriptor must be an object a tulajdonságleíró objektum kell legyen +@msg a property's {name} must be a function or undefined egy tulajdonság {name} értéke függvény vagy undefined lehet +@msg a row can't go into itself egy sor nem kerülhet önmagába +@msg a series goes on the left or the right value axis egy adatsor a bal vagy a jobb értéktengelyre kerül +@msg a start must be an anchor index ≥ 0 or null a kezdőpont ≥ 0 horgonypontindex vagy null lehet +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied egy benne lévő adatfolyam kibontva 256 MB-nál nagyobb lenne, vagy a dekódolási paraméterei nem alkalmazhatók +@msg a string holds a control character egy karakterlánc vezérlőkaraktert tartalmaz +@msg a string holds an escape sequence JSON does not have egy karakterlánc olyan escape-sorozatot tartalmaz, amely a JSON-ban nem létezik +@msg a string is not closed egy karakterlánc nincs lezárva +@msg a style named `{name}` already exists már van ilyen nevű stílus: `{name}` +@msg a style named `{to}` already exists már van ilyen nevű stílus: `{to}` +@msg a swatch needs a colour, gradient or pattern a színmintához szín, színátmenet vagy mintázat kell +@msg a symbol named `{n}` already exists már van ilyen nevű szimbólum: `{n}` +@msg a theme has 1 to {THEME_SIZE} colours egy témában 1–{THEME_SIZE} szín lehet +@msg a theme is already called {name} (replace: "{name}" overwrites it) már van ilyen nevű téma: {name} (a replace: "{name}" felülírja) +@msg a toolbar name has at most {MAX_NAME} characters az eszköztár neve legfeljebb {MAX_NAME} karakter lehet +@msg a view named `{_1}` exists már van ilyen nevű nézet: `{_1}` +@msg a view named `{name}` exists már van ilyen nevű nézet: `{name}` +@msg a workspace keeps at most {MAX} toolbars egy munkaterület legfeljebb {MAX} eszköztárat tárol +@msg a {_1} series can't go in a {_2} graph {_1} típusú adatsor nem kerülhet {_2} típusú diagramba +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-kompatibilis, ez az alapértelmezés) vagy "7" (régi .ai, 7-es verzió) +@msg already editing an opacity mask már folyik egy átlátszósági maszk szerkesztése +@msg already editing another object's opacity mask már folyik egy másik objektum átlátszósági maszkjának szerkesztése +@msg already in pattern editing mode már mintázatszerkesztési módban vagy +@msg an SVG filter is named by its id: give `xml` with `name` az SVG-szűrőt az azonosítója nevezi meg: a `name` mellé add meg az `xml` értéket +@msg an XML element has two attributes with the same name egy XML-elemnek két azonos nevű attribútuma van +@msg an XML value cannot be inserted into itself or its descendants egy XML-érték nem szúrható be önmagába vagy a leszármazottaiba +@msg an XML value's children cannot be assigned by position egy XML-érték gyermekei nem adhatók meg pozíció szerint +@msg an XML value's children cannot be deleted by position egy XML-érték gyermekei nem törölhetők pozíció szerint +@msg an XMLList converts to XML only when it has one item egy XMLList csak akkor alakítható XML-lé, ha egy eleme van +@msg an XPath node test is missing hiányzik egy XPath-csomópontteszt +@msg an XPath operand is missing hiányzik egy XPath-operandus +@msg an XPath operator is missing hiányzik egy XPath-operátor +@msg an XPath predicate or step cannot follow here itt nem következhet XPath-predikátum vagy -lépés +@msg an XPath predicate or step follows a value that is not a node-set egy XPath-predikátum vagy -lépés olyan érték után áll, amely nem csomóponthalmaz +@msg an XPath step is missing hiányzik egy XPath-lépés +@msg an artboard is listed twice egy rajztábla kétszer szerepel +@msg an empty artboard has no width or height to scale to üres rajztáblának nincs szélessége vagy magassága, amelyhez méretezni lehetne +@msg an image couldn't be decoded and is missing from the picture egy képet nem sikerült dekódolni, ezért hiányzik az ábráról +@msg an image is too large for DXF and was left out egy kép túl nagy a DXF-hez, ezért kimaradt +@msg an image of {_1} MB, at most {_2} MB a kép {_1} MB, legfeljebb {_2} MB lehet +@msg an image of {declared_w} × {declared_h} pixels is too large to draw egy {declared_w} × {declared_h} képpontos kép túl nagy a kirajzoláshoz +@msg an image of {width} × {height} pixels is too large to draw egy {width} × {height} képpontos kép túl nagy a kirajzoláshoz +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) egy {w} × {h} képpontos kép túl nagy az átalakításhoz (legfeljebb {_1} képpont) +@msg an index must be a whole number az index egész szám kell legyen +@msg an inline graphic has no glyphs egy szövegközi grafikának nincsenek glifái +@msg an invisible text form can't be read egy láthatatlan szöveg formaobjektuma nem olvasható +@msg an object is missing hiányzik egy objektum +@msg an object's header is missing hiányzik egy objektum fejléce +@msg an overprinting form can't be read egy felülnyomó formaobjektum nem olvasható +@msg an unread object has no readable bounds egy be nem olvasott objektumnak nincsenek olvasható határai +@msg an unread object holds a frame of threaded type egy be nem olvasott objektum láncolt szöveg keretét tartalmazza +@msg an unread object is missing from its layer egy be nem olvasott objektum hiányzik a rétegéből +@msg anchor `smooth` must be true or false horgonypont, `smooth`: true vagy false kell +@msg anchor `{key}` must be [x, y] horgonypont, `{key}`: [x, y] kell +@msg anchor `{key}` must be a number horgonypont, `{key}`: szám kell +@msg angle and roundness must be finite angle és roundness: véges érték kell +@msg angles must be a list as long as pins (degrees or null) angles: a pins listával azonos hosszúságú lista kell (fok vagy null) +@msg appearance must be flat|center|edge appearance: flat|center|edge kell +@msg apply is "fill" or "stroke", not `{other}` apply: "fill" vagy "stroke", ez nem az: `{other}` +@msg apply was called on a value that is not a function az apply hívása olyan értéken történt, amely nem függvény +@msg area must be a closed polygon of 3 to 100000 [x, y] points area: 3 és 100000 közötti számú [x, y] pontból álló zárt sokszög kell +@msg area type in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő területszöveg nem használhat dinamikus szöveget: {frames} +@msg area type needs a path that encloses an area a területszöveghez területet körülzáró útvonal kell +@msg area type {_1} is named twice a területszöveg kétszer szerepel: {_1} +@msg array literal too long a tömbliterál túl hosszú +@msg arrowAlign must be extend|tip, got {o} arrowAlign: extend|tip kell, kapott érték: {o} +@msg arrowScale must be [start, end] arrowScale: [start, end] kell +@msg artboard is a 0-based index artboard: 0-tól kezdődő index +@msg artboard must be a 0-based index artboard: 0-tól kezdődő index kell +@msg artboard {i} doesn't print ez a rajztábla nem kerül nyomtatásra: {i} +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout: {layout?, columns?, spacing?, rightToLeft?} kell +@msg artboardLayout.columns must be 1 or more artboardLayout.columns: 1 vagy nagyobb érték kell +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout: gridByRow, gridByColumn, row vagy column kell +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft: true vagy false kell +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing: 0–{MAX_SPACING} pt kell +@msg artboards must be a non-empty list artboards: nem üres lista kell +@msg artboards must be a number artboards: szám kell +@msg asset `{_1}` has no art to export ebben az elemben nincs exportálható grafika: `{_1}` +@msg assignment to a constant binding értékadás konstans kötésnek +@msg at least one row that isn't excluded needs a weight above 0 legalább egy ki nem zárt sornak 0-nál nagyobb súly kell +@msg at most {MAX_KEYWORDS} keywords legfeljebb {MAX_KEYWORDS} kulcsszó +@msg at most {MAX_MATCHES} matches are kept legfeljebb {MAX_MATCHES} találat marad meg +@msg at most {MAX_POINTS} points legfeljebb {MAX_POINTS} pont +@msg at most {MAX_QUEUE} files legfeljebb {MAX_QUEUE} fájl +@msg at must be [x, y] at: [x, y] kell +@msg at/rect lie outside the canvas at/rect: a vásznon kívül esik +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute: size|leading|tracking|kerning|baselineShift kell +@msg attrs must be an object attrs: objektum kell +@msg automation path rejected: the folder's name isn't valid text az automatizálás útvonala elutasítva: a mappa neve nem érvényes szöveg +@msg automation path rejected: {_1} isn't valid text az automatizálás útvonala elutasítva: nem érvényes szöveg: {_1} +@msg automation path rejected: {why}: {path} az automatizálás útvonala elutasítva: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis: "vertical", "horizontal" vagy fokban megadott szög kell, ez nem az: `{given}` +@msg backgroundContents must be transparent or white backgroundContents: transparent vagy white kell +@msg bad AES key hibás AES-kulcs +@msg bad `runs`: {e} hibás `runs`: {e} +@msg bad arrowScale hibás arrowScale +@msg bad base64 hibás base64 +@msg bad color {c} hibás szín: {c} +@msg bad colour hibás szín +@msg bad colour in `colors` hibás szín itt: `colors` +@msg bad colour {v} hibás szín: {v} +@msg bad colour {v} in groupColors hibás szín a groupColors értékben: {v} +@msg bad cross-reference entry hibás kereszthivatkozási bejegyzés +@msg bad cross-reference table hibás kereszthivatkozási tábla +@msg bad current colour {v} hibás jelenlegi szín: {v} +@msg bad dataBase64 hibás dataBase64 +@msg bad edit hibás szerkesztés +@msg bad fill {v} hibás kitöltés: {v} +@msg bad fit: {e} hibás illesztés: {e} +@msg bad model {other} (keep, new or document) hibás modell: {other} (keep, new vagy document) +@msg bad new colour {v} hibás új szín: {v} +@msg bad object hibás objektum +@msg bad object number hibás objektumszám +@msg bad offset hibás eltolás +@msg bad path data: {e} hibás útvonaladatok: {e} +@msg bad point {v}: expected [x, y] hibás pont: {v}; [x, y] kell +@msg bad point {v}: expected [x, y] or [x, y, pressure] hibás pont: {v}; [x, y] vagy [x, y, pressure] kell +@msg bad reference hibás hivatkozás +@msg bad trailer size hibás trailerméret +@msg bad {k} colour hibás szín: {k} +@msg batches cannot nest kötegek nem ágyazhatók egymásba +@msg bind something to a variable first előbb köss valamit egy változóhoz +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed: 0–{MAX_BLEED} pt kell: egy szám, [top, bottom, left, right] vagy {{top, bottom, left, right}} +@msg blend must be a mode name blend: keverési mód neve kell +@msg both sides of an XPath | must select nodes az XPath | mindkét oldalának csomópontokat kell kijelölnie +@msg brush must be a name or null brush: név vagy null kell +@msg by must be a number by: szám kell +@msg can't add page thumbnails: {why} nem adhatók hozzá oldalbélyegképek: {why} +@msg can't add the editing data for other apps: {why} nem adhatók hozzá a más alkalmazásoknak szánt szerkesztési adatok: {why} +@msg can't edit the written PDF: {why} a kiírt PDF nem szerkeszthető: {why} +@msg can't encrypt the PDF: {why} a PDF nem titkosítható: {why} +@msg can't linearise the PDF: {why} a PDF nem linearizálható: {why} +@msg can't open `{_1}`: {_2} nem nyitható meg: `{_1}`: {_2} +@msg can't open `{_1}`: {e} nem nyitható meg: `{_1}`: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) nem nyitható meg: `{name}`; ezt a formátumot a VectorCraft nem olvassa (lásd: document.formats) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) nem helyezhető el: `{name}`; ezt a formátumot a VectorCraft nem olvassa (lásd: document.formats) +@msg can't read back the written PDF: {e} a kiírt PDF nem olvasható vissza: {e} +@msg can't read this Word document: {why} ez a Word-dokumentum nem olvasható: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp .{e} képek nem írhatók: használd a .png, .jpg, .gif, .webp, .tif vagy .bmp formátumot +@msg can't write PDF layers, overprint or invisible text: {why} nem írhatók ki a PDF-rétegek, a felülnyomás vagy a láthatatlan szöveg: {why} +@msg can't write the file's copyright: {why} nem írható ki a fájl szerzői jogi adata: {why} +@msg can't write the file's modification date: {why} nem írható ki a fájl módosítási dátuma: {why} +@msg can't write the pages' user units: {why} nem írhatók ki az oldalak felhasználói egységei: {why} +@msg canceled megszakítva +@msg cannot convert the object to a primitive value az objektum nem alakítható primitív értékké +@msg cannot convert undefined or null to an object undefined vagy null nem alakítható objektummá +@msg cannot distort a zero-size or non-finite bounding box nulla méretű vagy nem véges határolókeret nem torzítható +@msg cannot move an object into itself egy objektum nem helyezhető át önmagába +@msg case must be upper|lower|title|sentence case: upper|lower|title|sentence kell +@msg category `{c}`: one of {_1} kategória `{c}`: ezek egyike: {_1} +@msg center must be [x, y] center: [x, y] kell +@msg character dimensions cannot be represented with the source text transform a karakterméretek nem ábrázolhatók a forrásszöveg transzformációjával +@msg character dimensions cannot be represented with the target text transform a karakterméretek nem ábrázolhatók a célszöveg transzformációjával +@msg choose at least one artboard válassz legalább egy rajztáblát +@msg choose at least one status válassz legalább egy állapotot +@msg clip must be true or false clip: true vagy false kell +@msg clippingMask must be true or false clippingMask: true vagy false kell +@msg closed must be a boolean closed: logikai érték kell +@msg collapsed must be true or false collapsed: true vagy false kell +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: nincs ilyen nevű RGB- vagy CMYK-profil: „{profile}” +@msg colorMode must be rgb or cmyk, not `{m}` colorMode: rgb vagy cmyk kell, ez nem az: `{m}` +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel: {own} (a dokumentum módja), grayscale vagy bitmap kell +@msg colour groups can't go into colour groups színcsoport nem kerülhet színcsoportba +@msg colour, mode and spot apply to solid-colour swatches only colour, mode és spot: csak egyszínű színmintákra vonatkozik +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read {column}. oszlop: `{cell}` a diagramadat-fájlok `%` oszlopa, ezek beolvasása nem történik meg +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable {column}. oszlop: `{cell}` ezt nevezi meg: `{name}`, amely {_1} típusú változó +@msg column {column}: `{cell}` names no variable {column}. oszlop: `{cell}` nem nevez meg változót +@msg column {column}: the variable name is longer than {MAX_NAME} characters {column}. oszlop: a változó neve hosszabb {MAX_NAME} karakternél +@msg column: true floats the whole column and takes no onto, above or below column: true az egész oszlopot lebegővé teszi, és nem adható meg mellette onto, above vagy below +@msg command must be a shape.* command command: shape.* parancs kell +@msg commands[{i}]: missing `command` commands[{i}]: hiányzik: `command` +@msg composer must be "singleLine" or "everyLine" composer: "singleLine" vagy "everyLine" kell +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes összetett alakzatba útvonalak, összetett útvonalak, csoportok, összetett alakzatok, szöveg, összemosások és burkolók kerülhetnek +@msg compound {_1} must contain exactly one open path to join az összetett objektumnak ({_1}) pontosan egy összekapcsolható nyitott útvonalat kell tartalmaznia +@msg connect needs a path other than the one extended connect: a meghosszabbított útvonaltól különböző útvonal kell +@msg connect needs id connect: id is kell hozzá +@msg connect path has no open end connect: az útvonalnak nincs nyitott vége +@msg continuation must be an array of path chunks continuation: útvonaldarabok tömbje kell +@msg continuation requires points continuation: points is kell hozzá +@msg copy needs path: the file to copy into the document's folder copy: path is kell hozzá: a dokumentum mappájába másolandó fájl +@msg copy takes images only: relink placed documents where their files are copy: csak képeket fogad; az elhelyezett dokumentumokat ott csatold újra, ahol a fájljaik vannak +@msg copying a file isn't available on the web a webes változatban nem lehet fájlt másolni +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus: unknown, copyrighted vagy publicDomain kell +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl: URL kell (szóköz nélkül, legfeljebb 2048 karakter) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners: 4 darab [x, y] pont kell (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item vagy file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: csak a PDF-oldalaknak van art, trim, bleed és media kerete; használd a "crop" vagy a "bounding" értéket +@msg crop rectangle doesn't overlap the image a vágási téglalap nem fedi a képet +@msg cross-reference and object streams aren't supported a kereszthivatkozási és objektumfolyamok nem támogatottak +@msg damaged compressed data: {e} sérült tömörített adatok: {e} +@msg dash lengths must be numbers, got {v} dash: a hosszak számok legyenek, kapott érték: {v} +@msg dash must be a list of lengths or null, got {v} dash: hosszak listája vagy null kell, kapott érték: {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) data kulcs `{k}`: betűk, számjegyek, -, _ és . (nem lehet name vagy vc-…) +@msg data must be an object of key: value, not {v} data: kulcs: érték párokból álló objektum kell, ez nem az: {v} +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare a(z) `{name}` adatkészlet értéket tartalmaz ehhez: `{var}`, de ezt a könyvtár nem deklarálja +@msg data set `{name}` holds two values for `{var}` a(z) `{name}` adatkészlet két értéket tartalmaz ehhez: `{var}` +@msg data set `{name}` is declared twice az adatkészlet kétszer van deklarálva: `{name}` +@msg data takes at most {MAX_DATA} keys at once data: egyszerre legfeljebb {MAX_DATA} kulcsot fogad +@msg dataset `{name}` already exists már van ilyen adatkészlet: `{name}` +@msg dataset `{to}` already exists már van ilyen adatkészlet: `{to}` +@msg date `{d}`: YYYY-MM-DD dátum `{d}`: YYYY-MM-DD +@msg decoded data is too large a dekódolt adatok túl nagyok +@msg define a variable first előbb definiálj egy változót +@msg detect must be true or false detect: true vagy false kell +@msg direct-select anchor points of a path jelöld ki közvetlen kijelöléssel egy útvonal horgonypontjait +@msg distance must be > 0 distance: > 0 érték kell +@msg dx and dy give the arrow's direction dx és dy: a nyíl irányát adják meg +@msg dx and dy must be numbers dx és dy: számok kellenek +@msg dxf fitTo must be [width, height], positive dxf fitTo: [width, height] kell, pozitív értékekkel +@msg dxf must be an object of DXF import options dxf: a DXF-importálási beállítások objektuma kell +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale: pozitív szám kell, legfeljebb {_1}; ez nem az: {s} +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: mértékegység kell, például mm, cm, in vagy pt +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} minden formátum egy {format, scale?, suffix?} objektum +@msg each item needs an id minden elemnek azonosító kell +@msg each match needs `kind` minden találathoz `kind` kell +@msg each match needs a `name` minden találathoz `name` kell +@msg each of `fonts` is {family, style?}; 1 to 1000 of them a `fonts` minden eleme {family, style?}; 1–1000 darab lehet +@msg each of `items` is {kind, item} az `items` minden eleme {kind, item} +@msg each of `items` is {{id, corners?}} with an object id, not {it} az `items` minden eleme {{id, corners?}} objektumazonosítóval, ez nem az: {it} +@msg each path chunk must contain 1 to 4096 points minden útvonaldarabnak 1–4096 pontot kell tartalmaznia +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) minden választás {{at: [x, y]}} vagy {{rect: [x0, y0, x1, y1]}} (kapott érték: {v}) +@msg each row needs `from` minden sorhoz `from` kell +@msg each stop needs a position minden színátmenetpontnak pozíció kell +@msg each subpath needs an anchors array minden részútvonalhoz anchors tömb kell +@msg each weight must be a number of 0 or more (got {v}) minden súly 0 vagy nagyobb szám legyen (kapott érték: {v}) +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType: easeIn, easeOut, easeInOut vagy easeOutIn kell +@msg editingData must be true or false editingData: true vagy false kell +@msg editingData must be true, false or "always" editingData: true, false vagy "always" kell +@msg edits overlap a szerkesztések átfedik egymást +@msg effects of objects whose appearances differ can't be moved together eltérő megjelenésű objektumok effektusai nem helyezhetők át együtt +@msg empty header in column {_1} üres fejléc ebben az oszlopban: {_1} +@msg encoding `{_1}`: utf8 or utf16 kódolás `{_1}`: utf8 vagy utf16 +@msg ends must be two of "first" and "last" ends: két érték kell ezekből: "first" és "last" +@msg every numbered name for the result is taken az eredmény minden számozott neve foglalt +@msg expanded SVG styles exceed the import size limit a kibontott SVG-stílusok túllépik az importálási méretkorlátot +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes az exportálási előbeállítások fájlja nagyobb, mint {MAX_PRESET_FILE_BYTES} bájt +@msg exportText must be editable or appearance exportText: editable vagy appearance kell +@msg exportType must be movieClip or graphic, not `{s}` exportType: movieClip vagy graphic kell, ez nem az: `{s}` +@msg extend needs id extend: id is kell hozzá +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges: legfeljebb {MAX_FEATURE_CHANGES} címkét sorolhat fel +@msg featureChanges must be a list of tags featureChanges: címkék listája kell +@msg features must be a list of tags features: címkék listája kell +@msg fidelity must be 0..100 fidelity: 0..100 kell +@msg files must be {name, dataBase64} files: {name, dataBase64} kell +@msg finally table {t} out of range a finally tábla tartományon kívül esik: {t} +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit: none|autoHeight|shrinkText|block kell, kapott érték: {id} +@msg flags cannot be given with a RegExp object RegExp objektum mellett nem adhatók meg jelzők +@msg flattener: {e} lapító: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low vagy egy mentett előbeállítás (lásd: flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile: along|across kell, kapott érték: {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item vagy itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels {MAX_BATCH_DEPTH} szintnél mélyebb mappák +@msg folders deeper than {MAX_DEPTH} levels {MAX_DEPTH} szintnél mélyebb mappák +@msg font family `{family}` is not available a betűcsalád nem érhető el: `{family}` +@msg font family `{tf}` is not available a betűcsalád nem érhető el: `{tf}` +@msg format `{f}`: pdf or postscript format `{f}`: pdf vagy postscript +@msg format `{f}`: text or json format `{f}`: text vagy json +@msg format is `{format}` a formátum: `{format}` +@msg formats must be an array of rows, not {v} formats: sorok tömbje kell, ez nem az: {v} +@msg frequency must be between 1 and 300 lines per inch frequency: 1 és 300 vonal/hüvelyk közötti érték kell +@msg from and to must be finite from és to: véges érték kell +@msg from must be [x0, y0, x1, y1] from: [x0, y0, x1, y1] kell +@msg function declaration index out of range a függvénydeklaráció indexe tartományon kívül esik +@msg give `before` or `after`, not both a `before` vagy az `after` értéket add meg, ne mindkettőt +@msg give `char`, `text`, `code` or `unicode` add meg a `char`, a `text`, a `code` vagy a `unicode` értéket +@msg give `colors`, or `color` and `rule` add meg a `colors` értéket, vagy a `color` és a `rule` értéket +@msg give `count` or `spacing`, not both a `count` vagy a `spacing` értéket add meg, ne mindkettőt +@msg give `event`, such as onclick add meg az `event` értéket, például onclick +@msg give `file`, the JavaScript file's path or URL add meg a `file` értéket: a JavaScript-fájl elérési útját vagy URL-jét +@msg give `fill` and/or `stroke` add meg a `fill` és/vagy a `stroke` értéket +@msg give `fill`, `stroke` or both add meg a `fill` vagy a `stroke` értéket, vagy mindkettőt +@msg give `folder` add meg a `folder` értéket +@msg give `fonts`, or open a document add meg a `fonts` értéket, vagy nyiss meg egy dokumentumot +@msg give `from` or `indices`, not both a `from` vagy az `indices` értéket add meg, ne mindkettőt +@msg give `id` (no text is being edited) add meg az `id` értéket (nincs szerkesztés alatt álló szöveg) +@msg give `id` and `anchors`, or `items`, not both az `id` és az `anchors` értéket, vagy az `items` értéket add meg, ne mindkettőt +@msg give `id` or `guide`, not both az `id` vagy a `guide` értéket add meg, ne mindkettőt +@msg give `length` or `to` add meg a `length` vagy a `to` értéket +@msg give `matches`: [{kind, name, target}] add meg a `matches` értéket: [{kind, name, target}] +@msg give `name` (or edit a pattern) add meg a `name` értéket (vagy szerkessz egy mintázatot) +@msg give `name` or `names` add meg a `name` vagy a `names` értéket +@msg give `name`, an SVG filter's name add meg a `name` értéket: egy SVG-szűrő nevét +@msg give `path` (a .wasm file) or `dataBase64` (the module) add meg a `path` (egy .wasm fájl) vagy a `dataBase64` (a modul) értéket +@msg give `path`, `data` or `dataBase64` add meg a `path`, a `data` vagy a `dataBase64` értéket +@msg give `path`, `data`, `dataBase64` or `csv` add meg a `path`, a `data`, a `dataBase64` vagy a `csv` értéket +@msg give `path`, `xml` or `dataBase64` add meg a `path`, az `xml` vagy a `dataBase64` értéket +@msg give `path`, an SVG file, or `svg`, SVG text add meg a `path` értéket (egy SVG-fájl) vagy az `svg` értéket (SVG-szöveg) +@msg give `script`, the JavaScript the event runs ("" removes the event) add meg a `script` értéket: az esemény által futtatott JavaScriptet (a "" eltávolítja az eseményt) +@msg give `text` (the reading) or `remove` add meg a `text` (az olvasat) vagy a `remove` értéket +@msg give `x` and `y` (and a `tolerance` of 0 or more) add meg az `x` és az `y` értéket (és egy 0 vagy nagyobb `tolerance` értéket) +@msg give `xml`, one SVG element add meg az `xml` értéket: egyetlen SVG elemet +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments adj meg egy módot, vagy r/g/b, c/m/y/k, gray vagy tint módosításokat +@msg give a name, or paint the active proxy with a saved preset adj meg egy nevet, vagy fesd az aktív mintadobozt egy mentett előbeállítással +@msg give a name, or select a stroke with a saved profile adj meg egy nevet, vagy jelölj ki egy mentett profilú körvonalat +@msg give a pattern name or select an object painted with a pattern adj meg egy mintázatnevet, vagy jelölj ki egy mintázattal festett objektumot +@msg give at least one of clip, invert, disabled, linked adj meg legalább egyet ezek közül: clip, invert, disabled, linked +@msg give color, none, swatch or gradient adj meg color, none, swatch vagy gradient értéket +@msg give dataBase64 add meg a dataBase64 értéket +@msg give either at (a click) or area (a path drawn around the overlaps) add meg az at (egy kattintás) vagy az area (az átfedések köré rajzolt útvonal) értéket +@msg give exactly one of `at`, `rect` and `points` pontosan egyet adj meg ezek közül: `at`, `rect`, `points` +@msg give exactly one of `path`, `data` and `dataBase64` pontosan egyet adj meg ezek közül: `path`, `data`, `dataBase64` +@msg give formats or a preset, not both a formats értéket vagy egy előbeállítást adj meg, ne mindkettőt +@msg give index or indexes, not both az index vagy az indexes értéket add meg, ne mindkettőt +@msg give newName or delete add meg a newName vagy a delete értéket +@msg give offset or by add meg az offset vagy a by értéket +@msg give one element egyetlen elemet adj meg +@msg give one of `to`, `rect` or `at` egyet adj meg ezek közül: `to`, `rect`, `at` +@msg give one of assets, layers, selection or fullDocument egyet adj meg ezek közül: assets, layers, selection, fullDocument +@msg give only one of onto, above, below, icon and a column place csak egyet adj meg ezek közül: onto, above, below, icon vagy egy oszlopbeli hely +@msg give opacity or blend add meg az opacity vagy a blend értéket +@msg give origin: [x, y], or reset: true add meg ezt: origin: [x, y], vagy ezt: reset: true +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note add meg az overprintFill, overprintStroke, showCenter, imageMap, url vagy note értéket +@msg give path (a file) or folder add meg a path (egy fájl) vagy a folder értéket +@msg give path, or name and dataBase64 add meg a path értéket, vagy a name és a dataBase64 értéket +@msg give paths or files add meg a paths vagy a files értéket +@msg give rows, rowHeight, columns or columnWidth add meg a rows, rowHeight, columns vagy columnWidth értéket +@msg give svg, or dataBase64 of an SVG or SVGZ file add meg az svg értéket, vagy egy SVG- vagy SVGZ-fájl dataBase64 értékét +@msg give text add meg a text értéket +@msg give the asset a name adj nevet az elemnek +@msg give the preset a `name` adj `name` értéket az előbeállításnak +@msg give the preset a name adj nevet az előbeállításnak +@msg give the preset a name (1–64 characters) adj nevet az előbeállításnak (1–64 karakter) +@msg give the settings to change, e.g. {"formats"}]} add meg a módosítandó beállításokat, például {"formats"}]} +@msg give the style a name adj nevet a stílusnak +@msg give two or more style `names` adj meg két vagy több stílusnevet a `names` értékben +@msg give two or more swatches adj meg két vagy több színmintát +@msg give x, y, or a handle's length or angle add meg az x, az y értéket, vagy egy fogantyú hosszát vagy szögét +@msg give {key, value} or {values} add meg ezt: {key, value} vagy {values} +@msg global must be true or false global: true vagy false kell +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) a(z) {_1} glifa nem ehhez a karakterhez rajzolódik ki a karakter stílusával: {ch} (ilyen például a Csupa nagybetű, a Kiskapitális vagy egy számrendszer) +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} a(z) {g} glifa nem alternatívája ennek: {ch} (betűtípus: {_1} {_2}) +@msg gradient must be "objects" or "mesh" gradient: "objects" vagy "mesh" kell +@msg grays must be "profile" or "black", not `{g}` grays: "profile" vagy "black" kell, ez nem az: `{g}` +@msg gridColors must be a preset name or two colours gridColors: előbeállítás neve vagy két szín kell +@msg gridSize must be small, medium or large gridSize: small, medium vagy large kell +@msg guide {g} is not selected a segédvonal nincs kijelölve: {g} +@msg handle must be 0..3 handle: 0..3 kell +@msg handler at {_1} expects {_2} values but its range starts with {d} a(z) {_1} helyen lévő kezelő {_2} értéket vár, de a tartománya ennyivel kezdődik: {d} +@msg handler depth too large a kezelő mélysége túl nagy +@msg handler range {_1}..{_2} out of range a kezelő tartománya ({_1}..{_2}) tartományon kívül esik +@msg hiddenLayers must be true or false hiddenLayers: true vagy false kell +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (ez az alapértelmezés), "omit" vagy "visible" +@msg highlight a layer to merge into emelj ki egy réteget, amelybe az egyesítés történjen +@msg highlight two or more rows in one layer or group emelj ki két vagy több sort egyetlen rétegben vagy csoportban +@msg highlight two or more rows to merge emelj ki két vagy több egyesítendő sort +@msg hvcC hvcC +@msg icon docks a panel in the icon column: use window.panel.dock icon: a panelt az ikonoszlopba dokkolja; használd a window.panel.dock parancsot +@msg id must be a dimension id id: méretvonal-azonosító kell +@msg image has no bounds a képnek nincsenek határai +@msg image {_1} is linked already a kép már csatolva van: {_1} +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client vagy server +@msg images in a spot ink whose samples couldn't be read were imported in RGB azok a direktszínű képek, amelyek mintái nem voltak olvashatók, RGB-ben lettek importálva +@msg images past the most pixels one file may hold were left out kimaradtak azok a képek, amelyek túllépték az egy fájlban megengedett legnagyobb képpontszámot +@msg images too large to read were left out a beolvasáshoz túl nagy képek kimaradtak +@msg index out of range az index tartományon kívül esik +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes: legfeljebb {MAX_GUIDE_INDEXES} elemet tartalmazhat +@msg indexes must be an array of guide indexes indexes: segédvonalindexek tömbje kell +@msg instances must be 1..1000 instances: 1..1000 kell +@msg instruction out of range az utasítás tartományon kívül esik +@msg instruction {pc} ({op}) pops below the stack a(z) {pc}. utasítás ({op}) a verem alja alól venne ki értéket +@msg instruction {pc} reached with stack depths {seen} and {d} a(z) {pc}. utasítás kétféle veremmélységgel érhető el: {seen} és {d} +@msg invalid `color` érvénytelen `color` +@msg invalid anchor index {v}: expected a non-negative integer érvénytelen horgonypontindex: {v}; nemnegatív egész szám kell +@msg invalid anchor reference {v}: expected [subpath, anchor] érvénytelen horgonypont-hivatkozás: {v}; [subpath, anchor] kell +@msg invalid angle érvénytelen szög +@msg invalid array length érvénytelen tömbhossz +@msg invalid brush: {e} érvénytelen ecset: {e} +@msg invalid color érvénytelen szín +@msg invalid colour {c} érvénytelen szín: {c} +@msg invalid delta érvénytelen eltérés +@msg invalid group érvénytelen csoport +@msg invalid object id {v}: expected a non-negative integer érvénytelen objektumazonosító: {v}; nemnegatív egész szám kell +@msg invalid point érvénytelen pont +@msg invalid regular expression flag '{_1}' érvénytelen jelző a reguláris kifejezésben: '{_1}' +@msg invalid regular expression: {m} érvénytelen reguláris kifejezés: {m} +@msg invalid size érvénytelen méret +@msg invalid t érvénytelen t +@msg invalid time value érvénytelen időérték +@msg invalid tolerance érvénytelen tűrés +@msg is a confirm's answer, whose `value` must be true or false egy megerősítés válasza, amelynek `value` értéke true vagy false lehet +@msg is a prompt's answer, whose `value` must be a string or null egy bekérés válasza, amelynek `value` értéke karakterlánc vagy null lehet +@msg is an alert's answer, whose `value` must be "ok" egy figyelmeztetés válasza, amelynek `value` értéke "ok" lehet +@msg is longer than 16 KiB hosszabb 16 KiB-nál +@msg it draws its image through a clipping path a képét vágóútvonalon keresztül rajzolja ki +@msg it draws no image nem rajzol ki képet +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes nagyobb, mint {MAX_DICTIONARY_FILE_BYTES} bájt +@msg it isn't a file nem fájl +@msg it names {_1} plates, and at most {MAX_PLATES} are read {_1} színkivonatot nevez meg, de legfeljebb {MAX_PLATES} olvasható be +@msg its art is too long or too detailed for a head a grafikája túl hosszú vagy túl részletes egy nyílhegyhez +@msg its art paints nothing a grafikája semmit sem fest +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) minden rajztáblája a vásznon kívül esik (több mint {_1} pt távolságra az origótól) +@msg its clipping masks are nested too deeply a vágómaszkjai túl mélyen vannak egymásba ágyazva +@msg its image is too large to read a képe túl nagy a beolvasáshoz +@msg its metadata has no description a metaadataiban nincs leírás +@msg its metadata has no direct length a metaadatainak nincs közvetlenül megadott hossza +@msg its plates are too large: more than {MAX_SAMPLES} pixels together a színkivonatai túl nagyok: együtt több mint {MAX_SAMPLES} képpont +@msg its plates are too large: {_1} plates of {w} × {h} pixels a színkivonatai túl nagyok: {_1} darab {w} × {h} képpontos színkivonat +@msg its plates can't be kept together a színkivonatai nem tarthatók együtt +@msg its shapes could not be combined into one az alakzatait nem sikerült eggyé egyesíteni +@msg join two different paths két különböző útvonalat kapcsolj össze +@msg jump to {pc}, past the end ugrás a végén túlra: {pc} +@msg justify must be auto|left|center|right|justifyAll justify: auto|left|center|right|justifyAll kell +@msg kbps must be a positive number, not {kbps} kbps: pozitív szám kell, ez nem az: {kbps} +@msg keywords must be a list of words or a comma-separated string keywords: szavak listája vagy vesszővel tagolt karakterlánc kell +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords: legfeljebb {MAX_KEYWORD} karakter lehet, vezérlőkarakterek nélkül +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind: 1, 2 vagy 3 kell, vagy adj meg egy előbeállítást a `name` értékben (lásd: perspective.presets.list) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind: graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle vagy paraStyle kell +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: "on", "off", "neutral" vagy logikai érték kell, ez nem az: {v} +@msg layer `{name}` has no art to export ebben a rétegben nincs exportálható grafika: `{name}` +@msg layer must name a layer or sublayer layer: réteget vagy alréteget kell megneveznie +@msg layers can't be intertwined: select the objects in them rétegek nem fonhatók össze: a bennük lévő objektumokat jelöld ki +@msg layers go only at the top level or in layers, objects in layers and groups rétegek csak a legfelső szintre vagy rétegekbe kerülhetnek, objektumok rétegekbe és csoportokba +@msg layers must be true or false layers: true vagy false kell +@msg layout must be gridByRow, gridByColumn, row or column layout: gridByRow, gridByColumn, row vagy column kell +@msg leading must be a number or "auto" leading: szám vagy "auto" kell +@msg left/right must be non-negative widths left/right: nemnegatív szélességek kellenek +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding: ezek egyike kell: {_1} +@msg level `{t}`: 2 or 3 level `{t}`: 2 vagy 3 +@msg line {line}: more than {MAX_CSV_COLS} columns {line}. sor: több mint {MAX_CSV_COLS} oszlop +@msg line {line}: unclosed quote {line}. sor: lezáratlan idézőjel +@msg line {line}: value for `{var}` must match its variable kind {line}. sor: a(z) `{var}` értékének meg kell felelnie a változó típusának +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf vagy crlf +@msg loading profiles from disk isn't available in the browser böngészőben nem lehet profilokat lemezről betölteni +@msg localize: the first argument must be a localization object or a string localize: az első argumentum lokalizációs objektum vagy karakterlánc kell legyen +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: {m} pt margó mellett nem marad nyomtatható terület a papíron +@msg matrix must be finite and invertible, depth finite matrix: véges és invertálható kell legyen, a depth véges +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member: az order front, forward, backward vagy back értékéhez való +@msg missing `artboards` hiányzik: `artboards` +@msg missing `at` [x, y] hiányzik: `at` [x, y] +@msg missing `attrs` hiányzik: `attrs` +@msg missing `color` or `opacity` hiányzik: `color` vagy `opacity` +@msg missing `colors` hiányzik: `colors` +@msg missing `commands` hiányzik: `commands` +@msg missing `destinationFolder` hiányzik: `destinationFolder` +@msg missing `effect` id hiányzik az `effect` azonosítója +@msg missing `family` hiányzik: `family` +@msg missing `find` hiányzik: `find` +@msg missing `gid` (a glyph id, or null for the default glyph) hiányzik: `gid` (glifaazonosító, vagy null az alapértelmezett glifához) +@msg missing `group` hiányzik: `group` +@msg missing `id` hiányzik: `id` +@msg missing `id`: give one, or select one type object hiányzik: `id`; adj meg egyet, vagy jelölj ki egy szövegobjektumot +@msg missing `index` (a point index, or null to clear) hiányzik: `index` (pontindex, vagy null a törléshez) +@msg missing `index` (a stop index, or null to clear) hiányzik: `index` (színátmenetpont-index, vagy null a törléshez) +@msg missing `index` (an item index or null) hiányzik: `index` (elemindex vagy null) +@msg missing `item` hiányzik: `item` +@msg missing `library` hiányzik: `library` +@msg missing `library` (see graphicStyle.libraries) hiányzik: `library` (lásd: graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) hiányzik: `library` (lásd: swatch.library.list) +@msg missing `map` (rows [{from, to}] or {current}) hiányzik: `map` ([{from, to}] sorok vagy {current}) +@msg missing `matrix` [a,b,c,d,e,f] hiányzik: `matrix` [a,b,c,d,e,f] +@msg missing `name` (see gradient.presets.list) hiányzik: `name` (lásd: gradient.presets.list) +@msg missing `offset` (a distance in points) hiányzik: `offset` (távolság pontban) +@msg missing `path` hiányzik: `path` +@msg missing `path` (a path object id) hiányzik: `path` (egy útvonalobjektum azonosítója) +@msg missing `picks` hiányzik: `picks` +@msg missing `rule` hiányzik: `rule` +@msg missing `source` id hiányzik a `source` azonosítója +@msg missing `start` (the byte offset of a character) hiányzik: `start` (egy karakter bájteltolása) +@msg missing `status`: linked, embedded, missing or modified hiányzik: `status`; linked, embedded, missing vagy modified lehet +@msg missing `target` id hiányzik a `target` azonosítója +@msg missing `text` hiányzik: `text` +@msg missing `to` hiányzik: `to` +@msg missing `to` (an index) hiányzik: `to` (egy index) +@msg missing `to`: a non-negative integer hiányzik: `to`; nemnegatív egész szám kell +@msg missing `type` hiányzik: `type` +@msg missing `value` hiányzik: `value` +@msg missing `word` hiányzik: `word` +@msg missing `{key}` hiányzik: `{key}` +@msg missing `{k}` hiányzik: `{k}` +@msg missing anchor hiányzik a horgonypont +@msg missing anchors hiányoznak a horgonypontok +@msg missing anchors [[subpath, anchor]…] hiányoznak a horgonypontok: [[subpath, anchor]…] +@msg missing array `{key}` hiányzik a tömb: `{key}` +@msg missing case hiányzik: case +@msg missing color hiányzik a szín +@msg missing commands hiányoznak a parancsok +@msg missing destination folder hiányzik a célmappa +@msg missing from hiányzik: from +@msg missing group hiányzik a csoport +@msg missing id or ids hiányzik: id vagy ids +@msg missing ids hiányzik: ids +@msg missing indexes hiányzik: indexes +@msg missing matrix [a,b,c,d,e,f] hiányzik: matrix [a,b,c,d,e,f] +@msg missing name hiányzik a név +@msg missing op hiányzik: op +@msg missing or invalid `color` hiányzó vagy érvénytelen `color` +@msg missing or unknown `tool` hiányzó vagy ismeretlen `tool` +@msg missing point hiányzik a pont +@msg missing point [x,y] hiányzik a pont: [x,y] +@msg missing point `{key}` ([x, y]) hiányzik a pont: `{key}` ([x, y]) +@msg missing points hiányoznak a pontok +@msg missing points [[x,y]…] hiányoznak a pontok: [[x,y]…] +@msg missing segment hiányzik a szakasz +@msg missing stops hiányoznak a színátmenetpontok +@msg missing subpaths hiányoznak a részútvonalak +@msg missing target hiányzik a cél +@msg missing to hiányzik: to +@msg missing x, y hiányzik: x, y +@msg mode is set, add or toggle, not `{other}` mode: set, add vagy toggle, ez nem az: `{other}` +@msg mode must be "cmyk" or "rgb" mode: "cmyk" vagy "rgb" kell +@msg mode must be area|onPath, got {o} mode: area|onPath kell, kapott érték: {o} +@msg more than 10000 linked files: package dependencies in smaller parts több mint 10000 csatolt fájl: kisebb részletekben csomagold a függőségeket +@msg more than 256 commands több mint 256 parancs +@msg more than {MAX_BATCH_FILES} data sets több mint {MAX_BATCH_FILES} adatkészlet +@msg more than {MAX_BATCH_FILES} files több mint {MAX_BATCH_FILES} fájl +@msg more than {MAX_CSV_ROWS} data rows több mint {MAX_CSV_ROWS} adatsor +@msg more than {MAX_FILES} images több mint {MAX_FILES} kép +@msg more than {MAX_IMPORT} presets több mint {MAX_IMPORT} előbeállítás +@msg moved must be [[x,y]…] moved: [[x,y]…] kell +@msg must be an object with `kind` and `value` `kind` és `value` kulcsot tartalmazó objektum kell +@msg must have `kind` alert, confirm or prompt a `kind` értéke alert, confirm vagy prompt kell legyen +@msg must have exactly `kind` and `value` pontosan a `kind` és a `value` kulcsot kell tartalmaznia +@msg name (a toolbar's name) is required name (egy eszköztár neve): kötelező +@msg name (a toolbar's name) must be a string name (egy eszköztár neve): karakterlánc kell +@msg name must be a toolbar's name name: egy eszköztár neve kell +@msg names is empty names: üres +@msg names must be an array of symbol names names: szimbólumnevek tömbje kell +@msg need `anchor` or `segment` + `t` `anchor` vagy `segment` + `t` kell +@msg need at least one anchor legalább egy horgonypont kell +@msg need at least one point legalább egy pont kell +@msg need at least two points legalább két pont kell +@msg needs a closing quote after the name a név után záró idézőjel kell +@msg needs a name név kell +@msg needs a name of at most 64 bytes legfeljebb 64 bájtos név kell +@msg needs a name of letters, digits and _, or a quoted name betűkből, számjegyekből és _ jelből álló név vagy idézőjelbe tett név kell +@msg needs a name without control characters vezérlőkarakterek nélküli név kell +@msg needs points pontok kellenek +@msg nesting too deep túl mély egymásba ágyazás +@msg nineSlice must be true or false nineSlice: true vagy false kell +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides: [left, top, right, bottom] kell, mindegyik -16..16, ahol left + right < 1 és top + bottom < 1 +@msg no HP-GL format nincs HP-GL formátum +@msg no New Document profile called `{shown}` ({_1}) nincs ilyen nevű új dokumentumprofil: `{shown}` ({_1}) +@msg no PDF preset named `{n}` nincs ilyen nevű PDF-előbeállítás: `{n}` +@msg no SVG filter at index {k} nincs SVG-szűrő ennél az indexnél: {k} +@msg no action set is current nincs aktuális műveletkészlet +@msg no active data set nincs aktív adatkészlet +@msg no appearance item {index} nincs ilyen megjelenési elem: {index} +@msg no arrowheads file here itt nincs nyílhegyfájl +@msg no artboard {_1} nincs ilyen rajztábla: {_1} +@msg no artboard {artboard} (0-based; the document has {_1}) nincs ilyen rajztábla: {artboard} (0-tól számozva; a dokumentumban {_1} van) +@msg no asset {id} nincs ilyen elem: {id} +@msg no asset {id} (see assets.list) nincs ilyen elem: {id} (lásd: assets.list) +@msg no assets to export nincs exportálható elem +@msg no assets yet: collect art for export first (assets.add) még nincsenek elemek: előbb gyűjts grafikát az exportáláshoz (assets.add) +@msg no brush named `{n}` nincs ilyen nevű ecset: `{n}` +@msg no character at byte {start} nincs karakter ennél a bájtnál: {start} +@msg no character has code {code} egyetlen karakternek sem ez a kódja: {code} +@msg no colour nincs szín +@msg no colour group `{group}` nincs ilyen színcsoport: `{group}` +@msg no colour group `{name}` nincs ilyen színcsoport: `{name}` +@msg no custom arrowhead {name} (see stroke.arrowheads.list) nincs ilyen egyéni nyílhegy: {name} (lásd: stroke.arrowheads.list) +@msg no data sets nincsenek adatkészletek +@msg no dataset named `{from}` nincs ilyen nevű adatkészlet: `{from}` +@msg no dataset named `{name}` nincs ilyen nevű adatkészlet: `{name}` +@msg no datasets nincsenek adatkészletek +@msg no design named `{name}` nincs ilyen nevű diagramminta: `{name}` +@msg no design named `{n}` nincs ilyen nevű diagramminta: `{n}` +@msg no design named `{old}` nincs ilyen nevű diagramminta: `{old}` +@msg no encoder for {_1} yet ehhez még nincs kódoló: {_1} +@msg no export preset `{name}` (see document.exportPresets.list) nincs ilyen exportálási előbeállítás: `{name}` (lásd: document.exportPresets.list) +@msg no file to open nincs megnyitandó fájl +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder nincs mappa: add meg a `path` értéket, vagy állítsd be ezt: Beállítások › Bővítmények & Munkalemezek › További bővítmények mappája +@msg no fonts to look for: they are all available nincs keresendő betűtípus: mindegyik elérhető +@msg no formats to export nincs exportálandó formátum +@msg no gradient preset `{name}` (see gradient.presets.list) nincs ilyen színátmenet-előbeállítás: `{name}` (lásd: gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` nincs ilyen grafikai stílus: `{n}` (itt: `{_1}`) +@msg no graphic style library `{key}` (see graphicStyle.libraries) nincs ilyen grafikaistílus-könyvtár: `{key}` (lásd: graphicStyle.libraries) +@msg no guide selected nincs kijelölt segédvonal +@msg no guide selected (or give an index) nincs kijelölt segédvonal (vagy adj meg egy indexet) +@msg no guide {i} nincs ilyen segédvonal: {i} +@msg no images in the folder a mappában nincsenek képek +@msg no instructions nincsenek utasítások +@msg no item at index {_1} nincs elem ennél az indexnél: {_1} +@msg no item at index {from} nincs elem ennél az indexnél: {from} +@msg no item at one of the indices {idx} az indexek egyikénél nincs elem: {idx} +@msg no layer nincs réteg +@msg no layer to rename nincs átnevezhető réteg +@msg no layer {id} (see layer.list) nincs ilyen réteg: {id} (lásd: layer.list) +@msg no layers to export nincs exportálható réteg +@msg no library `{key}` nincs ilyen könyvtár: `{key}` +@msg no metafile format nincs metafájlformátum +@msg no object {_1} nincs ilyen objektum: {_1} +@msg no objects nincsenek objektumok +@msg no parent layer nincs szülőréteg +@msg no perspective grid preset named `{b}` (see perspective.presets.list) nincs ilyen nevű perspektivikusrács-előbeállítás: `{b}` (lásd: perspective.presets.list) +@msg no perspective grid preset named `{n}` nincs ilyen nevű perspektivikusrács-előbeállítás: `{n}` +@msg no point selected (give `index`) nincs kijelölt pont (add meg az `index` értéket) +@msg no preset called `{from}` (see file.newPresets) nincs ilyen nevű előbeállítás: `{from}` (lásd: file.newPresets) +@msg no preset called `{name}` (see file.newPresets) nincs ilyen nevű előbeállítás: `{name}` (lásd: file.newPresets) +@msg no preset named `{n}` nincs ilyen nevű előbeállítás: `{n}` +@msg no print preset named `{b}` (see print.presets.list) nincs ilyen nevű nyomtatási előbeállítás: `{b}` (lásd: print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) nincs ilyen nevű nyomtatási előbeállítás: `{name}` (előbeállítások: {_1}) +@msg no print preset named `{n}` nincs ilyen nevű nyomtatási előbeállítás: `{n}` +@msg no profile named `{name}` nincs ilyen nevű profil: `{name}` +@msg no random numbers ({e}) nincsenek véletlenszámok ({e}) +@msg no readable files in the folder a mappában nincsenek olvasható fájlok +@msg no recovery copy `{f}` (see file.recovery.list) nincs ilyen helyreállítási másolat: `{f}` (lásd: file.recovery.list) +@msg no rows nincsenek sorok +@msg no saved PDF preset named `{name}` nincs ilyen nevű mentett PDF-előbeállítás: `{name}` +@msg no saved export preset `{name}` nincs ilyen mentett exportálási előbeállítás: `{name}` +@msg no saved gradient preset called {name} nincs ilyen nevű mentett színátmenet-előbeállítás: {name} +@msg no saved gradient presets nincs mentett színátmenet-előbeállítás +@msg no saved preset called `{name}` nincs ilyen nevű mentett előbeállítás: `{name}` +@msg no saved presets to export (name [Default] in `names` to export it) nincs exportálható mentett előbeállítás (az exportálásához nevezd meg a [Default] előbeállítást a `names` értékben) +@msg no saved presets to export (name built-in ones in `names`) nincs exportálható mentett előbeállítás (a beépítetteket a `names` értékben nevezd meg) +@msg no saved print preset named `{name}` nincs ilyen nevű mentett nyomtatási előbeállítás: `{name}` +@msg no saved profile called {name} nincs ilyen nevű mentett profil: {name} +@msg no saved selection `{name}` nincs ilyen mentett kijelölés: `{name}` +@msg no saved theme called {name} (see colorTheme.list) nincs ilyen nevű mentett téma: {name} (lásd: colorTheme.list) +@msg no saved views nincsenek mentett nézetek +@msg no selected or given object has a visible 3D effect egyik kijelölt vagy megadott objektumnak sincs látható 3D effektusa +@msg no series {i} nincs ilyen adatsor: {i} +@msg no slice {n} in the image (see slice.list) a képen nincs ilyen szelet: {n} (lásd: slice.list) +@msg no source art nincs forrásgrafika +@msg no stop {i} (the gradient has {n}) nincs ilyen színátmenetpont: {i} (a színátmenetnek {n} van) +@msg no style `{name}` nincs ilyen stílus: `{name}` +@msg no such artboard {v} nincs ilyen rajztábla: {v} +@msg no such assets (see assets.list) nincsenek ilyen elemek (lásd: assets.list) +@msg no such frame anchor, or the frame would reach past the canvas nincs ilyen kerethorgonypont, vagy a keret túlnyúlna a vásznon +@msg no such objects nincsenek ilyen objektumok +@msg no such path anchor, or the path would reach past the canvas nincs ilyen útvonal-horgonypont, vagy az útvonal túlnyúlna a vásznon +@msg no such rows nincsenek ilyen sorok +@msg no swatch `{keep}` nincs ilyen színminta: `{keep}` +@msg no swatch `{n}` nincs ilyen színminta: `{n}` +@msg no swatch `{n}` (swatches and colour groups move separately) nincs ilyen színminta: `{n}` (a színminták és a színcsoportok külön mozgathatók) +@msg no swatch library `{key}` (see swatch.library.list) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list; "document": a dokumentum színmintái) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list; "{DOCUMENT_SWATCHES}": a dokumentum színmintái) +@msg no swatch named `{n}` nincs ilyen nevű színminta: `{n}` +@msg no swatch or colour group `{n}` nincs ilyen színminta vagy színcsoport: `{n}` +@msg no swatch or colour group `{n}` in `{_1}` nincs ilyen színminta vagy színcsoport: `{n}` (itt: `{_1}`) +@msg no symbol named `{_1}` nincs ilyen nevű szimbólum: `{_1}` +@msg no symbol named `{name}` nincs ilyen nevű szimbólum: `{name}` +@msg no text objects selected nincs kijelölt szövegobjektum +@msg no toolbar named `{_1}` nincs ilyen nevű eszköztár: `{_1}` +@msg no toolbar named `{name}` nincs ilyen nevű eszköztár: `{name}` +@msg no tracing preset `{preset}` (see imageTrace.presets) nincs ilyen vektorizálási előbeállítás: `{preset}` (lásd: imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) itt nincs felhasználói könyvtármappa (ments elérési úttal, vagy anélkül, hogy megkapd az adatokat) +@msg no variable named `{from}` nincs ilyen nevű változó: `{from}` +@msg no variable named `{name}` nincs ilyen nevű változó: `{name}` +@msg no variable named `{var}` nincs ilyen nevű változó: `{var}` +@msg no variable named `{v}` nincs ilyen nevű változó: `{v}` +@msg no view named `{name}` nincs ilyen nevű nézet: `{name}` +@msg node {_1} already has the text variable `{_2}` a(z) {_1} csomópontnak már van szövegváltozója: `{_2}` +@msg node {_1} is not a graph nem diagram ez a csomópont: {_1} +@msg node {_1} is not an image nem kép ez a csomópont: {_1} +@msg node {_1} is not type nem szöveg ez a csomópont: {_1} +@msg not a DCS file nem DCS-fájl +@msg not a PDF presets file (format `{_1}`) nem PDF-előbeállításfájl (formátum: `{_1}`) +@msg not a PDF presets file: {e} nem PDF-előbeállításfájl: {e} +@msg not a RegExp object nem RegExp objektum +@msg not a Word document: its WordDocument stream doesn't start with a file information block nem Word-dokumentum: a WordDocument adatfolyama nem fájlinformációs blokkal kezdődik +@msg not a Word document: its compound file has no WordDocument stream nem Word-dokumentum: az összetett fájljában nincs WordDocument adatfolyam +@msg not a Word document: its main part is not a WordprocessingML document nem Word-dokumentum: a fő része nem WordprocessingML-dokumentum +@msg not a Word document: its package has no main document part nem Word-dokumentum: a csomagjában nincs fő dokumentumrész +@msg not a closed path nem zárt útvonal +@msg not a flattener presets file (format `{_1}`) nem lapítási előbeállításfájl (formátum: `{_1}`) +@msg not a flattener presets file: {e} nem lapítási előbeállításfájl: {e} +@msg not a layer or group nem réteg és nem csoport +@msg not a library file: {e} nem könyvtárfájl: {e} +@msg not a mesh envelope point nem hálós burkoló pontja +@msg not a perspective grid presets file (format `{_1}`) nem perspektivikusrács-előbeállításfájl (formátum: `{_1}`) +@msg not a perspective grid presets file: {e} nem perspektivikusrács-előbeállításfájl: {e} +@msg not a print presets file (format `{_1}`) nem nyomtatási előbeállításfájl (formátum: `{_1}`) +@msg not a print presets file: {e} nem nyomtatási előbeállításfájl: {e} +@msg not a regular expression nem reguláris kifejezés +@msg not a stored or deflated central record nem tárolt vagy deflate-tel tömörített központi rekord +@msg not a valid SVGZ file: {e} nem érvényes SVGZ-fájl: {e} +@msg not an RTF file: it doesn't start with {\\rtf nem RTF-fájl: nem ezzel kezdődik: {\\rtf +@msg not an action set: the text doesn't start with /version and a number nem műveletkészlet: a szöveg nem a /version szóval és egy számmal kezdődik +@msg not an image VectorCraft reads (see document.formats) nem olyan kép, amelyet a VectorCraft olvas (lásd: document.formats) +@msg not area type (text in a frame) outside perspective nem perspektíván kívüli területszöveg (keretben lévő szöveg) +@msg not editing an opacity mask nem folyik átlátszósági maszk szerkesztése +@msg not type on a path outside perspective nem perspektíván kívüli útvonalszöveg +@msg nothing prints semmi sem kerül nyomtatásra +@msg nothing to bind nincs mit kötni +@msg nothing to change nincs mit módosítani +@msg nothing to change: give dx, dy, hScale, vScale or rotation nincs mit módosítani: add meg a dx, dy, hScale, vScale vagy rotation értéket +@msg nothing to crop nincs mit körülvágni +@msg nothing to export nincs mit exportálni +@msg nothing to export: the document has no visible art nincs mit exportálni: a dokumentumban nincs látható grafika +@msg nothing to mark nincs mit megjelölni +@msg nothing to print: every artboard picked is blank nincs mit nyomtatni: minden kiválasztott rajztábla üres +@msg nothing to print: the document has no art that prints nincs mit nyomtatni: a dokumentumban nincs nyomtatásra kerülő grafika +@msg nothing to repeat nincs mit ismételni +@msg numbers out of order in {_1} quantifier a számok rossz sorrendben vannak ebben a kvantorban: {_1} +@msg object is not a path az objektum nem útvonal +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object az objektum átlátszósága nem vehető át kiemelt karakterekre; kapcsold ki az Átlátszóság beállítást a Pipetta beállításaiban, vagy jelöld ki a teljes objektumot +@msg object {_1} can't be set as point type ez az objektum nem állítható be pontszövegként: {_1} +@msg object {_1} has lost its file's contents: relink it ez az objektum elvesztette a fájlja tartalmát: {_1}; csatold újra +@msg object {_1} has no path ennek az objektumnak nincs útvonala: {_1} +@msg object {_1} has no size ennek az objektumnak nincs mérete: {_1} +@msg object {_1} is hidden or locked, or on a locked artboard ez az objektum rejtett vagy zárolt, vagy zárolt rajztáblán van: {_1} +@msg object {_1} is not a placed document ez az objektum nem elhelyezett dokumentum: {_1} +@msg object {_1} is not an Intertwine group ez az objektum nem összefonási csoport: {_1} +@msg object {_1} is not an image ez az objektum nem kép: {_1} +@msg object {_1} is not an image or a linked file ez az objektum nem kép és nem csatolt fájl: {_1} +@msg object {_1} is not an image or a placed document ez az objektum nem kép és nem elhelyezett dokumentum: {_1} +@msg object {_1} is not in this Intertwine group ez az objektum nincs ebben az összefonási csoportban: {_1} +@msg object {_1} needs a 3D effect ehhez az objektumhoz 3D effektus kell: {_1} +@msg object {_1} needs a 3D effect at index {index} a(z) {_1} objektumhoz 3D effektus kell ennél az indexnél: {index} +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic {_1} objektum: az üreges Kihúzás és élletörés effektusnak nincsenek fedőlapjai; grafika leképezéséhez kapcsold be a Fedőlap beállítást +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front {_1} objektum: 0 mélységnél az objektumnak egy lapja van; a grafikáit az előlapra képezd le +@msg object {_1}: {_2} {_1} objektum: {_2} +@msg object {_1}: {e} {_1} objektum: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams {_1} objektum: a(z) {index} indexű {name} effektusnak nincsenek megvilágítási előbeállításai; a fényeit az effect.setParams paranccsal állítsd be +@msg object {id} has anchors 0–{_1} for `corners`, not {past} a(z) {id} objektum horgonypontjai a `corners` értékhez: 0–{_1}; ilyen nincs: {past} +@msg object {id} has {n} anchors, so no anchor {past} a(z) {id} objektumnak {n} horgonypontja van, ezért ilyen nincs: {past} +@msg objects must be none, move or copy objects: none, move vagy copy kell +@msg objects were left out objektumok maradtak ki +@msg offset out of range az eltolás tartományon kívül esik +@msg on must be true or false on: true vagy false kell +@msg only groups, layers, type and images have a Contents (Characters) row csak a csoportoknak, a rétegeknek, a szövegnek és a képeknek van Tartalom (Karakterek) sora +@msg only interior mesh lines can be deleted csak belső hálóvonalak törölhetők +@msg only solid colours can be added (not gradients or patterns) csak egyszínű színek adhatók hozzá (színátmenetek és mintázatok nem) +@msg only solid-colour swatches can be spot colours csak egyszínű színminták lehetnek direktszínek +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható: a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt, vagy előbb exportálj SVG-t vagy PDF-et az Affinityből +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható; a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon és az oszlopbeli hely: a window.panel.float és a window.panel.dock parancshoz tartoznak +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon és az oszlopbeli hely: nem adható meg mellettük anchor, side, zone vagy before +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon és az oszlopbeli hely: nem adható meg mellettük x, y, width vagy height +@msg opacity must be a number opacity: szám kell +@msg open a document to look for its linked files a csatolt fájlok kereséséhez nyiss meg egy dokumentumot +@msg operand too large az operandus túl nagy +@msg options must be an object (see file.formatOptions) options: objektum kell (lásd: file.formatOptions) +@msg order is a whole number from 0 to {others}, not `{v}` order: 0 és {others} közötti egész szám, ez nem az: `{v}` +@msg order must be leftToRight or rightToLeft order: leftToRight vagy rightToLeft kell +@msg orientation must be portrait or landscape, not `{o}` orientation: portrait vagy landscape kell, ez nem az: `{o}` +@msg outlineText {v}: true or false outlineText {v}: true vagy false +@msg output intent profile: {e} kimeneti cél profilja: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: nincs ilyen nevű RGB- vagy CMYK-profil: „{name}” +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: a(z) {_1} fájlok CMYK színmódúak, „{name}” pedig RGB-profil +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: egyetlen festék sincs nyomtatásra állítva +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: nincs ilyen nevű profil: „{name}”, a(z) {std} kimeneti cél pedig beágyazza a profilját +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: nincs ilyen nevű profil: „{name}”; a(z) {std} kimeneti cél beágyaz egy profilt, vagy megnevez egy regisztrált nyomtatási feltételt (outputConditionId és registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: a(z) {std} fájlok CMYK-ban nyomtatódnak, „{name}” pedig RGB-profil +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: „{name}” szürke profil, amely nem lehet {std} kimeneti cél +@msg overlap: {o} pt is half a tile or more overlap: {o} pt egy fél csempe vagy annál több +@msg overlapping Shaper paths must be in the same layer or group az egymást átfedő Alakformáló-útvonalaknak ugyanabban a rétegben vagy csoportban kell lenniük +@msg overlapping edits egymást átfedő szerkesztések +@msg page art conflicts with image data for {key} az oldal grafikája ütközik ennek a képadataival: {key} +@msg page art conflicts with pattern {name} az oldal grafikája ütközik ezzel a mintázattal: {name} +@msg page art conflicts with swatch {name} az oldal grafikája ütközik ezzel a színmintával: {name} +@msg page art is missing image data for {key} az oldal grafikájából hiányoznak ennek a képadatai: {key} +@msg page art is missing pattern {name} az oldal grafikájából hiányzik ez a mintázat: {name} +@msg page art is missing swatch {name} az oldal grafikájából hiányzik ez a színminta: {name} +@msg page must be a whole number from 1 page: 1-től kezdődő egész szám kell +@msg page thumbnail: {e} oldalbélyegkép: {e} +@msg page {page} of the PDF has no objects a PDF {page}. oldalán nincsenek objektumok +@msg page {page}: `{file}` has {n} {artboards} {page}. oldal: `{file}` tartalma: {n} {artboards} +@msg page {page}: `{name}` has {n} {pages} {page}. oldal: `{name}` tartalma: {n} {pages} +@msg pages must be a range such as "2-3" or a page number, not {v} pages: tartomány (például "2-3") vagy oldalszám kell, ez nem az: {v} +@msg pages: {e} pages: {e} +@msg panel position must be finite a panel pozíciója véges érték kell legyen +@msg parameters must be an object parameters: objektum kell +@msg params must be an object params: objektum kell +@msg passwords can have up to {MAX_PASSWORD} bytes a jelszavak legfeljebb {MAX_PASSWORD} bájtosak lehetnek +@msg path chunks must share their boundary point az útvonaldarabok határpontjának közösnek kell lennie +@msg path has no open end az útvonalnak nincs nyitott vége +@msg paths must be strings paths: karakterláncok kellenek +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false esetén minden rajztábla kell ahhoz, hogy a szerkeszthető grafika megmaradjon +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false esetén a rajztáblák kellenek a szerkeszthető grafika megtartásához, ha a useArtboards értéke false +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single vagy multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom: "artwork" vagy "swatches", ez nem az: {v} +@msg pins and moved must be non-empty lists of the same length pins és moved: azonos hosszúságú, nem üres listák kellenek +@msg pins must be [[x,y]…] pins: [[x,y]…] kell +@msg pins must be finite pins: véges értékek kellenek +@msg place is above, below or inside, not `{other}` place: above, below vagy inside, ez nem az: `{other}` +@msg plane must be left|right|ground plane: left|right|ground kell +@msg plane must be left|right|ground|none plane: left|right|ground|none kell +@msg point is outside the mesh a pont a hálón kívül esik +@msg point x must be a number a pont x értéke szám kell legyen +@msg point y must be a number a pont y értéke szám kell legyen +@msg points must be [t, left, right] with non-negative widths points: [t, left, right] kell nemnegatív szélességekkel +@msg points must be [x,y] pairs points: [x,y] párok kellenek +@msg points must be a non-empty [[x,y,pressure?]…] list points: nem üres [[x,y,pressure?]…] lista kell +@msg points must be a non-empty list of finite [x,y] points: véges [x,y] értékek nem üres listája kell +@msg points must be an array or null points: tömb vagy null kell +@msg points must be finite points: véges értékek kellenek +@msg points must be finite [x,y] pairs points: véges [x,y] párok kellenek +@msg points must be finite canvas points points: véges vászonpontok kellenek +@msg points must contain 1–4096 coordinates points: 1–4096 koordinátát kell tartalmaznia +@msg position must be normal, superscript or subscript position: normal, superscript vagy subscript kell +@msg position must be topLeft, topRight, bottomLeft or bottomRight position: topLeft, topRight, bottomLeft vagy bottomRight kell +@msg positions must be between 0 and 100000 pt positions: 0 és 100000 pt közötti értékek kellenek +@msg ppi must be a positive number, not {ppi} ppi: pozitív szám kell, ez nem az: {ppi} +@msg ppi must be between 1 and 2400 ppi: 1 és 2400 közötti érték kell +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}`: nem transforms|bounds|fileDimensions|fit|fill +@msg preserveShape must be clippingMask|transparency preserveShape: clippingMask|transparency kell +@msg preset `{id}`: mobile or density preset `{id}`: mobile vagy density +@msg preset must be standard, diffuse, topLeft or right preset: standard, diffuse, topLeft vagy right kell +@msg preset must be the name of a print preset preset: egy nyomtatási előbeállítás neve kell +@msg previewMode must be default, pixel or overprint previewMode: default, pixel vagy overprint kell +@msg print settings: {e} nyomtatási beállítások: {e} +@msg procedures nest too deeply az eljárások túl mélyen ágyazódnak egymásba +@msg procedures nested too deeply az eljárások túl mélyen vannak egymásba ágyazva +@msg quotes must be {double?, single?} quotes: {double?, single?} kell +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k}: egy nyitó és egy záró idézőjel kell, például "“”" +@msg range out of order in character class rossz sorrendű tartomány a karakterosztályban +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi: 1–2400 kell (72 képernyő, 150 közepes, 300 magas) +@msg rating must be a whole number from 0 to {MAX_RATING} rating: 0 és {MAX_RATING} közötti egész szám kell +@msg rect and {k} contradict each other: give the area or the artboards rect és {k}: ellentmondanak egymásnak; a területet vagy a rajztáblákat add meg +@msg rect applies to raster formats, not {_1} rect: raszteres formátumokra vonatkozik, erre nem: {_1} +@msg rect is [x, y, width, height] in points rect: [x, y, width, height] pontban megadva +@msg rect must be [x, y, width, height] rect: [x, y, width, height] kell +@msg rect must be [x, y, width, height] with a positive width and height rect: [x, y, width, height] kell pozitív szélességgel és magassággal +@msg rect needs finite numbers and a positive width and height rect: véges számok, valamint pozitív szélesség és magasság kell +@msg redraw needs id redraw: id is kell hozzá +@msg redraw within must be a finite distance of 0 or more redraw within: 0 vagy nagyobb véges távolság kell +@msg reduce of an empty array with no initial value reduce hívása üres tömbön, kezdőérték nélkül +@msg registration must be 0..8 registration: 0..8 kell +@msg regular expression flag '{_1}' given twice a reguláris kifejezés jelzője kétszer szerepel: '{_1}' +@msg release a layer or a group réteget vagy csoportot oldj fel +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace: megtartja a lecserélt objektum helyét és transzformációját; hagyd el a template, at és rect értéket +@msg replace: select the one object to replace replace: jelöld ki azt az egy objektumot, amelyet le kell cserélni +@msg resolution must be a number or screen|medium|high resolution: szám vagy screen|medium|high kell +@msg resolution must be between 1 and 2400 ppi resolution: 1 és 2400 ppi közötti érték kell +@msg rotation must be a number of degrees rotation: fokban megadott szám kell +@msg rotation must be finite rotation: véges érték kell +@msg rows and cols must be 1..50 rows és cols: 1..50 kell +@msg rows and columns must be 1..500 rows és columns: 1..500 kell +@msg rows of objects whose appearances differ can't be reordered together eltérő megjelenésű objektumok sorai nem rendezhetők át együtt +@msg sampled text would exceed the canvas (coordinates out of range) a mintavételezett szöveg túllépné a vásznat (a koordináták tartományon kívül esnek) +@msg save the document first: Package collects a saved document előbb mentsd a dokumentumot: a Csomagolás mentett dokumentumot gyűjt össze +@msg save the document first: copy puts the file in its folder előbb mentsd a dokumentumot: a copy a dokumentum mappájába teszi a fájlt +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: szorzó (például 2x), szélesség (100w), magasság (100h) vagy felbontás (72ppi) kell +@msg scale must be a number above 0, at most 64 scale: 0-nál nagyobb, legfeljebb 64 értékű szám kell +@msg scale must be non-zero scale: nem lehet nulla +@msg scope must be selection or all (got {other}) scope: selection vagy all kell (kapott érték: {other}) +@msg scribble is too long a firka túl hosszú +@msg segment does not exist a szakasz nem létezik +@msg select a Shaper group jelölj ki egy alakformáló csoportot +@msg select a blend to set easing or color shift a gyorsítás vagy a színeltolás beállításához jelölj ki egy összemosást +@msg select a compound shape jelölj ki egy összetett alakzatot +@msg select a graph jelölj ki egy diagramot +@msg select a group or layer with visible, unlocked members jelölj ki egy csoportot vagy réteget, amelynek látható, nem zárolt tagjai vannak +@msg select a layer jelölj ki egy réteget +@msg select a text object jelölj ki egy szövegobjektumot +@msg select an SVG filter jelölj ki egy SVG-szűrőt +@msg select an image (or an Image Trace group) jelölj ki egy képet (vagy egy képvektorizálási csoportot) +@msg select an image, or pass id jelölj ki egy képet, vagy add meg az id értéket +@msg select an object (or pass `id`) jelölj ki egy objektumot (vagy add meg az `id` értéket) +@msg select an object with a 3D effect jelölj ki egy 3D effektussal rendelkező objektumot +@msg select an object with a 3D effect, or give its id in ids jelölj ki egy 3D effektussal rendelkező objektumot, vagy add meg az azonosítóját az ids értékben +@msg select an object with a freeform gradient jelölj ki egy szabad formájú színátmenettel rendelkező objektumot +@msg select an object with an opacity mask jelölj ki egy átlátszósági maszkkal rendelkező objektumot +@msg select area type jelölj ki területszöveget +@msg select area type (text in a frame) jelölj ki területszöveget (keretben lévő szöveget) +@msg select area type and closed paths (at least one area type object) jelölj ki területszöveget és zárt útvonalakat (legalább egy területszöveg-objektumot) +@msg select area type whose first line can fit its frame jelölj ki olyan területszöveget, amelynek az első sora elfér a keretében +@msg select artwork to add its colours jelölj ki grafikát a színei hozzáadásához +@msg select artwork to make a group of its colours jelölj ki grafikát, hogy a színeiből csoport készüljön +@msg select artwork to recolour, or give a colour `group` jelölj ki átszínezendő grafikát, vagy adj meg színcsoportot a `group` értékben +@msg select exactly two path ends to join pontosan két összekapcsolandó útvonalvéget jelölj ki +@msg select members of a compound shape jelöld ki egy összetett alakzat tagjait +@msg select objects or give ids jelölj ki objektumokat, vagy add meg az ids értéket +@msg select objects to align to the guide jelöld ki a segédvonalhoz igazítandó objektumokat +@msg select one Intertwine group egyetlen összefonási csoportot jelölj ki +@msg select one image or pass id egyetlen képet jelölj ki, vagy add meg az id értéket +@msg select one layer, group or object to rename egyetlen átnevezendő réteget, csoportot vagy objektumot jelölj ki +@msg select paths jelölj ki útvonalakat +@msg select paths or compound paths jelölj ki útvonalakat vagy összetett útvonalakat +@msg select rows or objects to collect jelöld ki az összegyűjtendő sorokat vagy objektumokat +@msg select something to export jelölj ki valamit az exportáláshoz +@msg select something to export (or turn off selectedOnly) jelölj ki valamit az exportáláshoz (vagy kapcsold ki a selectedOnly beállítást) +@msg select text to redefine the style from jelölj ki szöveget, amelyből a stílus újradefiniálható +@msg select the art and, on top of it, the mask object jelöld ki a grafikát és a fölötte lévő maszkobjektumot +@msg select the art for the design jelöld ki a diagrammintához szánt grafikát +@msg select the art for the pattern tile jelöld ki a mintacsempéhez szánt grafikát +@msg select the art to add first előbb jelöld ki a hozzáadandó grafikát +@msg select the art to collect (or give its ids) jelöld ki az összegyűjtendő grafikát (vagy add meg az azonosítóit) +@msg select the art to weed jelöld ki a gyomlálandó grafikát +@msg select the content and a path on top jelöld ki a tartalmat és egy fölötte lévő útvonalat +@msg select the images to relink, or pass ids jelöld ki az újracsatolandó képeket, vagy add meg az ids értéket +@msg select the images, or pass ids jelöld ki a képeket, vagy add meg az ids értéket +@msg select the linked images or placed documents, or pass ids jelöld ki a csatolt képeket vagy az elhelyezett dokumentumokat, vagy add meg az ids értéket +@msg select the object to copy from jelöld ki azt az objektumot, amelyről a másolás történjen +@msg select the objects (or give their ids) jelöld ki az objektumokat (vagy add meg az azonosítóikat) +@msg select the objects to attach to the path jelöld ki az útvonalhoz csatolandó objektumokat +@msg select the objects to bind jelöld ki a kötendő objektumokat +@msg select two or more objects to intertwine jelölj ki két vagy több összefonandó objektumot +@msg select two or more slices to combine jelölj ki két vagy több egyesítendő szeletet +@msg select type on a path jelölj ki útvonalszöveget +@msg select type on a path or area type jelölj ki útvonalszöveget vagy területszöveget +@msg selection `{m}`: one or each selection `{m}`: one vagy each +@msg settings is an object of document.exportForScreens params settings: a document.exportForScreens paramétereiből álló objektum +@msg settings must be an object settings: objektum kell +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified vagy embedded +@msg show must be true or false show: true vagy false kell +@msg side is left or right, not `{v}` side: left vagy right, ez nem az: `{v}` +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: "small", "medium", "large" vagy 0-nál nagyobb, legfeljebb {_1} pontérték kell +@msg size must be positive size: pozitív érték kell +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode: "fixed" vagy "pressure", ez nem az: `{other}` +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode: "fixed" vagy "pressure", ez nem az: {other} +@msg sizeVariation must be 0 or more sizeVariation: 0 vagy nagyobb érték kell +@msg smallCaps must be true or false smallCaps: true vagy false kell +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind vagy status +@msg source paths are too large for Shaper a forrásútvonalak túl nagyok az Alakformálóhoz +@msg source text transform is collapsed or unrepresentable a forrásszöveg transzformációja összeesett vagy nem ábrázolható +@msg spot, mode and group apply to solid colours only spot, mode és group: csak egyszínű színekre vonatkozik +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing vagy modified +@msg steps must be 1..1000 steps: 1..1000 kell +@msg steps must be 1..{MAX_STEPS} steps: 1..{MAX_STEPS} kell +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) {i}. színátmenetpont: `plates`: négy 0 és 1 közötti szám kell (C, M, Y, K) +@msg stopped before the archive was finished leállt, mielőtt az archívum elkészült volna +@msg strokeMode must be within|along|across, got {m} strokeMode: within|along|across kell, kapott érték: {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions: a stroke.set beállításaiból álló objektum kell +@msg strokeWidth must be a positive number of pixels strokeWidth: képpontban megadott pozitív szám kell +@msg subpath {si} is not an open subpath ez a részútvonal nem nyitott: {si} +@msg subpaths required when joining the same path ugyanazon útvonal összekapcsolásakor a subpaths kötelező +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict: "merge" vagy "add" kell, ez nem az: {v} +@msg table must be true or false table: true vagy false kell +@msg takes one name egy nevet fogad +@msg target text transform is collapsed or unrepresentable a célszöveg transzformációja összeesett vagy nem ábrázolható +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text: {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} kell +@msg text transform is collapsed or unrepresentable a szöveg transzformációja összeesett vagy nem ábrázolható +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces: 2 és 100 közötti egész szám kell +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: a "{_1}" vagy a "{_2}" értéket használd +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: add meg egy képfájl {path} vagy {name, dataBase64} értékét, vagy egy textúrát úgy, ahogy az effect.list megadja +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices ez több mint {MAX_PIECES} szeletet eredményezne +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer ez túl sok darabot (legfeljebb 100000) vagy szaggatási lépést eredményezne: egyszerűsítsd a szaggatási mintát, vagy növeld a vonalkák hosszát +@msg that object is not in the overlap ez az objektum nincs az átfedésben +@msg the Affinity document has no page with a size az Affinity-dokumentumban nincs mérettel rendelkező oldal +@msg the CMYK image couldn't be written a CMYK-képet nem sikerült kiírni +@msg the Layers panel is not shown a Rétegek panel nem látható +@msg the Layers panel's search, filter or Show Layers Only hides its row a Rétegek panel keresése, szűrője vagy a Csak rétegek megjelenítése beállítás elrejti a sorát +@msg the Objects on Path group is hidden or locked az Objektumok útvonalon csoport rejtett vagy zárolt +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected a PDF biztonsági beállításai a jelszavak nélkül lettek mentve: add meg újra az openPassword vagy a permissionsPassword értéket, vagy a védelem nélküli exportáláshoz a security értéket passwordRequired nélkül +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) az RTF-fájlban túl sok a bekezdés vagy a formázásváltás ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {MAX_PARAGRAPHS} bekezdés és {MAX_RUNS} szövegrész) +@msg the RTF file is too large to place as type (at most {_1} MB) az RTF-fájl túl nagy ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the SVG expands to more than {0} nested SVG images az SVG kibontva több mint {0} beágyazott SVG-képet tartalmaz +@msg the SVG expands to more than {0} objects az SVG kibontva több mint {0} objektumot tartalmaz +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes az SVG-fájl nagyobb, mint {MAX_IMPORT_BYTES} bájt +@msg the SVG has no drawable objects az SVG-ben nincsenek kirajzolható objektumok +@msg the SVGZ file unpacks to more than {_1} MB az SVGZ-fájl kicsomagolva nagyobb, mint {_1} MB +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place a Word-dokumentumban több mint {MAX_PAGES} oldalnyi tulajdonság van, ez túl sok az elhelyezéshez +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place a Word-dokumentumban több mint {MAX_RUNS} karakter- vagy bekezdéstulajdonság-szakasz van, ez túl sok az elhelyezéshez +@msg the Word document has more than {_1} MB of formatting properties, too many to place a Word-dokumentumban több mint {_1} MB formázási tulajdonság van, ez túl sok az elhelyezéshez +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) a Word-dokumentumban túl sok a szöveg vagy a formázás ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {MAX_PARAGRAPHS} bekezdés, {MAX_RUNS} szövegrész és {_1} MB szöveg) +@msg the Word document has too much text to place as type (at most {_1} MB) a Word-dokumentumban túl sok a szöveg ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it a Word-dokumentum titkosított vagy jelszóval védett; az elhelyezéshez mentsd jelszó nélkül +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) a Word-dokumentum túl nagy az elhelyezéshez (a(z) {name} adatfolyama több mint {_1} MB) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off a Word-dokumentum utoljára gyorsmentéssel lett mentve, amelyet a VectorCraft nem olvas; mentsd újra kikapcsolt gyorsmentéssel +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document a Word-dokumentumot a Word 6.0 vagy a Word 95 mentette, ezt a VectorCraft nem olvassa; mentsd Word 97-2003 dokumentumként +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place a Word-dokumentum szövege több mint {MAX_PIECES} darabból áll, ez túl sok az elhelyezéshez +@msg the XML operation needs an XML or XMLList value az XML-művelethez XML vagy XMLList érték kell +@msg the XPath expression does not evaluate to a node-set az XPath-kifejezés eredménye nem csomóponthalmaz +@msg the XPath expression ends early az XPath-kifejezés túl korán ér véget +@msg the XPath expression has an unmatched ) az XPath-kifejezésben pár nélküli ) van +@msg the XPath expression has an unmatched ] az XPath-kifejezésben pár nélküli ] van +@msg the XPath expression has an unmatched closing bracket az XPath-kifejezésben pár nélküli záró zárójel van +@msg the XPath expression selects a namespace node, which is not an XML value az XPath-kifejezés névtércsomópontot jelöl ki, amely nem XML-érték +@msg the XPath expression selects the root node, which is not an XML value az XPath-kifejezés a gyökércsomópontot jelöli ki, amely nem XML-érték +@msg the XPath function {name} needs a node-set ehhez az XPath-függvényhez csomóponthalmaz kell: {name} +@msg the XPath function {name} takes the wrong number of arguments ez az XPath-függvény nem megfelelő számú argumentumot kapott: {name} +@msg the XPath name functions need a node-set az XPath névfüggvényeihez csomóponthalmaz kell +@msg the XPath prefix {shown} is not bound to a namespace ez az XPath-előtag nincs névtérhez kötve: {shown} +@msg the XPath variable ${shown} is not bound ez az XPath-változó nincs kötve: ${shown} +@msg the action set ids are not a list a műveletkészlet-azonosítók nem alkotnak listát +@msg the active paint is not a gradient az aktív festés nem színátmenet +@msg the argument list is not an object az argumentumlista nem objektum +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size a rajztáblák nem férnek el a vásznon: kevesebb rajztábla, kisebb térköz vagy kisebb méret kell +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out a grafika túl nagy az EPS-hez: ami 256 MB PostScript után következik, az kimaradt +@msg the bitmaps hold more pixels than one file may a bitképek több képpontot tartalmaznak, mint amennyi egy fájlban megengedett +@msg the character's size can't be measured a karakter mérete nem mérhető meg +@msg the code runs past its last instruction a kód túlfut az utolsó utasításán +@msg the compound shape is too complex to expand az összetett alakzat túl bonyolult a kibontáshoz +@msg the container of the listed objects is gone a felsorolt objektumok tárolója már nincs meg +@msg the copy `{user_id}` can't be read as a library a másolat nem olvasható be könyvtárként: `{user_id}` +@msg the copy would leave the canvas a másolat kikerülne a vászonról +@msg the curve tolerance must be from 0.01 to 10 points a görbetűrés 0,01 és 10 pont között lehet +@msg the custom dictionary couldn't be saved to {_1}: {e} az egyéni szótárat nem sikerült ide menteni: {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is az egyéni szótár nem olvasható: {_1} ({why}), ezért változatlan marad +@msg the cut cannot be made on this art: {e} a vágás ezen a grafikán nem végezhető el: {e} +@msg the data isn't an EMF or WMF picture az adatok nem EMF- vagy WMF-képet alkotnak +@msg the data isn't an EPS or PostScript picture az adatok nem EPS- vagy PostScript-képet alkotnak +@msg the deferred paste is no longer available a halasztott beillesztés már nem érhető el +@msg the design is too big to draw at every data point of this graph a diagramminta túl nagy ahhoz, hogy a diagram minden adatpontjánál kirajzolható legyen +@msg the design is too big to draw in every column of this graph a diagramminta túl nagy ahhoz, hogy a diagram minden oszlopában kirajzolható legyen +@msg the dialog it was opened over is no longer open a párbeszédablak, amely fölött megnyílt, már nincs nyitva +@msg the dictionary stack overflowed a szótárverem túlcsordult +@msg the document already has an SVG filter named {name} a dokumentumban már van ilyen nevű SVG-szűrő: {name} +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it a dokumentumban van egy másik ilyen nevű SVG-szűrő: {name}; használd az svgFilter.apply parancsot, vagy a szerkesztéséhez az svgFilter.define parancsot a `replace` értékkel +@msg the document has no SVG filter named {name} a dokumentumban nincs ilyen nevű SVG-szűrő: {name} +@msg the document has no SVG filter named {r} a dokumentumban nincs ilyen nevű SVG-szűrő: {r} +@msg the document has no color swatches a dokumentumban nincsenek színes színminták +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 a dokumentum színmintái {_1} színt tartalmaznak; a document színcsökkentés legfeljebb 255-öt fogad +@msg the editing data's hash has no place a szerkesztési adatok kivonatának nincs helye +@msg the entities the document declares expand to too much text a dokumentumban deklarált entitások túl sok szöveggé bomlanak ki +@msg the envelope's content has no bounds a burkoló tartalmának nincsenek határai +@msg the envelope's mesh is malformed a burkoló hálója hibás szerkezetű +@msg the file holds no `variableSets` element a fájlban nincs `variableSets` elem +@msg the file is larger than {_1} MB a fájl nagyobb, mint {_1} MB +@msg the file is too large a fájl túl nagy +@msg the file isn't valid SVG: {e} a fájl nem érvényes SVG: {e} +@msg the file names no plate a fájl nem nevez meg színkivonatot +@msg the file was updated or encrypted already a fájl már frissítve vagy titkosítva lett +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) a sor első karaktere előtt nincs karakter, amelyhez alávágható lenne: csak felfelé és lefelé mozdul (dy) +@msg the font {_1} is not available ez a betűtípus nem érhető el: {_1} +@msg the format has no file extension a formátumnak nincs fájlkiterjesztése +@msg the function's prototype is not an object a függvény prototípusa nem objektum +@msg the graphic can't be read: {e} a grafika nem olvasható: {e} +@msg the graphic is empty a grafika üres +@msg the graphic's data is damaged a grafika adatai sérültek +@msg the graphic's document has no artboard a grafika dokumentumában nincs rajztábla +@msg the guide is hidden or locked a segédvonal rejtett vagy zárolt +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them a(z) `{name}` változó `{cell}` fejléce a(z) `{other}` változóként olvasódna vissza: nevezd át az egyiket +@msg the image `{path}` isn't followed: {r} a kép nem lesz követve: `{path}`: {r} +@msg the image has no embedded pixels a képnek nincsenek beágyazott képpontjai +@msg the image is empty a kép üres +@msg the image is fully transparent a kép teljesen átlátszó +@msg the image is not on an artboard; pass rect a kép nincs rajztáblán; add meg a rect értéket +@msg the image's pixels are missing a kép képpontjai hiányoznak +@msg the image's pixels aren't in the document a kép képpontjai nincsenek a dokumentumban +@msg the image's pixels can't be read: {e} a kép képpontjai nem olvashatók: {e} +@msg the image's transparency doesn't fit it a kép átlátszósága nem illik a képhez +@msg the job would print more than {MAX_PAGES} pages a feladat több mint {MAX_PAGES} oldalt nyomtatna +@msg the job would print more than {_1} pages a feladat több mint {_1} oldalt nyomtatna +@msg the layers hold no art to mask a rétegekben nincs maszkolható grafika +@msg the layout moved az elrendezés elmozdult +@msg the library has a group `{name}` already a könyvtárban már van ilyen csoport: `{name}` +@msg the library has no group `{name}` a könyvtárban nincs ilyen csoport: `{name}` +@msg the library has no item `{item}` a könyvtárban nincs ilyen elem: `{item}` +@msg the library has no item `{other}` a könyvtárban nincs ilyen elem: `{other}` +@msg the library holds {MAX_GROUPS} groups already a könyvtár már {MAX_GROUPS} csoportot tartalmaz +@msg the library holds {MAX_ITEMS} items of this kind already a könyvtár már {MAX_ITEMS} ilyen fajta elemet tartalmaz +@msg the library's file is outside the read roots a könyvtár fájlja kívül esik az olvasási gyökereken +@msg the line is too long, or the paths it passes too many, to check a vonal túl hosszú, vagy túl sok útvonalon halad át az ellenőrzéshez +@msg the method needs an XML value a metódushoz XML-érték kell +@msg the name is empty a név üres +@msg the object has no fill, stroke or effect az objektumnak nincs kitöltése, körvonala vagy effektusa +@msg the object has no graphic style (pass `name`) az objektumnak nincs grafikai stílusa (add meg a `name` értéket) +@msg the objects don't match the plan az objektumok nem egyeznek a tervvel +@msg the open and permissions passwords must differ a megnyitási és a jogosultsági jelszónak különböznie kell +@msg the operand stack overflowed az operandusverem túlcsordult +@msg the page has no area az oldalnak nincs területe +@msg the page's type in an unread object's box isn't in its page art az oldal egy be nem olvasott objektum keretében lévő szövege nincs benne az oldal grafikájában +@msg the paint behind the proxy is not a gradient a mintadoboz mögötti festés nem színátmenet +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) a festés nem szabad formájú színátmenet (alkalmazz egyet a paint.editGradient {kind} paranccsal) +@msg the parameters or body do not form one function a paraméterek vagy a törzs nem alkotnak egyetlen függvényt +@msg the parent must be a layer or sublayer a szülő réteg vagy alréteg kell legyen +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it a beillesztett SVG-szűrő ({name}) másképp rajzol, mint a dokumentum azonos nevű szűrője, és nem jut neki szabad név +@msg the path can't be extended az útvonal nem hosszabbítható meg +@msg the path can't be inside one of the objects to attach az útvonal nem lehet az egyik csatolandó objektum belsejében +@msg the path has no segments to measure az útvonalnak nincs mérhető szakasza +@msg the path is empty az útvonal üres +@msg the path is too short to shorten that much az útvonal túl rövid ahhoz, hogy ennyivel rövidíthető legyen +@msg the paths are too many or too tangled to find where they cross túl sok az útvonal, vagy túlságosan összegabalyodtak ahhoz, hogy a metszéspontjaik megtalálhatók legyenek +@msg the pen table holds more than 255 pens a tolltábla több mint 255 tollat tartalmaz +@msg the picture has no objects az ábrán nincsenek objektumok +@msg the plates can't be kept a színkivonatok nem tarthatók meg +@msg the position table does not match the instructions a pozíciótábla nem egyezik az utasításokkal +@msg the program runs too long a program túl sokáig fut +@msg the program uses too much memory a program túl sok memóriát használ +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes a bekérés szövege hosszabb {MAX_PROMPT_BYTES} bájtnál +@msg the radix must be between 2 and 36 a számrendszer alapja 2 és 36 között lehet +@msg the rectangle must have a width and a height a téglalapnak szélesség és magasság kell +@msg the rectangle needs a size and must lie on the canvas a téglalapnak méret kell, és a vásznon kell lennie +@msg the rectangle or line reaches too many paths to check a téglalap vagy a vonal túl sok útvonalat ér el az ellenőrzéshez +@msg the right side of 'in' is not an object az 'in' jobb oldala nem objektum +@msg the right side of 'instanceof' is not a function az 'instanceof' jobb oldala nem függvény +@msg the selected art has no width a kijelölt grafikának nincs szélessége +@msg the selected art is nested more than {MAX_DEPTH} levels deep a kijelölt grafika {MAX_DEPTH} szintnél mélyebben van egymásba ágyazva +@msg the selected art is too large to measure a kijelölt grafika túl nagy a méréshez +@msg the selection draws nothing a kijelölés semmit sem rajzol ki +@msg the selection has no area a kijelölésnek nincs területe +@msg the selection has no art to export a kijelölésben nincs exportálható grafika +@msg the selection has no bounds a kijelölésnek nincsenek határai +@msg the selection has no graphic style a kijelölésnek nincs grafikai stílusa +@msg the selection has no outlines to weed around a kijelölésnek nincs körvonala, amely körül gyomlálni lehetne +@msg the slice would leave the canvas a szelet kikerülne a vászonról +@msg the spot colour couldn't be added a direktszínt nem sikerült hozzáadni +@msg the subpath has no ends to extend (it is closed or a single point) a részútvonalnak nincs meghosszabbítható vége (zárt, vagy egyetlen pontból áll) +@msg the symbols are nested too deeply to expand their instances a szimbólumok túl mélyen vannak egymásba ágyazva a példányaik kibontásához +@msg the text file is too large to place as type (at most {_1} MB) a szövegfájl túl nagy ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the text is empty a szöveg üres +@msg the text is over {_1} MB a szöveg nagyobb, mint {_1} MB +@msg the top object must be a path a legfelső objektum útvonal kell legyen +@msg the trace was cancelled a vektorizálás megszakítva +@msg the trailer has no size a trailernek nincs mérete +@msg the type object is flattened to nothing a szövegobjektumból a lapítás után semmi sem marad +@msg the value bind was called on is not a function az érték, amelyen a bind hívása történt, nem függvény +@msg the value is not a constructor az érték nem konstruktor +@msg the value is not a function az érték nem függvény +@msg the value is not a valid XML name az érték nem érvényes XML-név +@msg the value is not an XML attribute name az érték nem XML-attribútumnév +@msg the value is not an XML name az érték nem XML-név +@msg the value of axis `{tag}` must be a number or null a(z) `{tag}` tengely értéke szám vagy null lehet +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets a változókönyvtár több mint {MAX_LIBRARY_DATASETS} adatkészletet deklarál +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities a változókönyvtár több mint {MAX_LIBRARY_ENTITIES} entitást deklarál +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables a változókönyvtár több mint {MAX_LIBRARY_VARIABLES} változót deklarál +@msg the variable library declares no variables a változókönyvtár nem deklarál változókat +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes a változókönyvtár nagyobb, mint {MAX_LIBRARY_BYTES} bájt +@msg the variable library is not UTF-8 a változókönyvtár nem UTF-8 kódolású +@msg the variable library is not well-formed XML: {e} a változókönyvtár nem jól formált XML: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep a változókönyvtár elemei {MAX_LIBRARY_NESTING} szintnél mélyebben vannak egymásba ágyazva +@msg the variable library's document type declaration holds something other than entities of plain text a változókönyvtár dokumentumtípus-deklarációja mást is tartalmaz, mint egyszerű szöveges entitásokat +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes a változókönyvtár entitásai kibontva nagyobbak, mint {MAX_LIBRARY_BYTES} bájt +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines a gyomlálóvonalak túl sokszor metszik a grafika körvonalait a méréshez; használj kevesebb vonalat +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap a gyomlálóvonalak túl sok körvonalszakaszhoz haladnak túl közel a méréshez; használj kevesebb vonalat vagy kisebb hézagot +@msg the weeding lines would be more than {max_pieces} pieces a gyomlálóvonalak több mint {max_pieces} darabból állnának +@msg the window has no dock az ablaknak nincs dokkja +@msg the written PDF has no catalog a kiírt PDF-nek nincs katalógusa +@msg the written PDF has no cross-reference table a kiírt PDF-nek nincs kereszthivatkozási táblája +@msg the written PDF's metadata can't be edited a kiírt PDF metaadatai nem szerkeszthetők +@msg the {_1} plate lies outside the file ez a színkivonat a fájlon kívül esik: {_1} +@msg there are no colour swatches to draw nincs kirajzolható színes színminta +@msg there is already a design named `{name}` már van ilyen nevű diagramminta: `{name}` +@msg there is already a design named `{to}` már van ilyen nevű diagramminta: `{to}` +@msg there is already a toolbar named `{name}` már van ilyen nevű eszköztár: `{name}` +@msg there is already a toolbar named `{to}` már van ilyen nevű eszköztár: `{to}` +@msg there is no library: create one first nincs könyvtár: előbb hozz létre egyet +@msg these paths are too degenerate for Shaper ezek az útvonalak túlságosan elfajultak az Alakformálóhoz +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that ez bináris DXF-fájl, amely nem olvasható: mentsd ASCII DXF-ként a CAD-alkalmazásból, és azt nyisd meg +@msg this warp's points can't be edited: use Reset with Mesh first ennek a hajlításnak a pontjai nem szerkeszthetők: előbb használd a Visszaállítás hálóval parancsot +@msg thumbnail: no page {v} (the {what} has {count}) bélyegkép: nincs ilyen oldal: {v} (a(z) {what} oldalszáma: {count}) +@msg tileRange of {what}: {e} tileRange ({what}): {e} +@msg title can't be empty title: nem lehet üres +@msg to (the new name) is required to (az új név): kötelező +@msg to must be fill or stroke to: fill vagy stroke kell +@msg to must be smooth or corner to: smooth vagy corner kell +@msg toExponential() digits must be between 0 and 20 toExponential(): a számjegyek száma 0 és 20 között lehet +@msg toFixed() digits must be between 0 and 20 toFixed(): a számjegyek száma 0 és 20 között lehet +@msg toISOString is not a function a toISOString nem függvény +@msg toPrecision() argument must be between 1 and 21 toPrecision(): az argumentum 1 és 21 között lehet +@msg toSource: a getter cannot be written as source toSource: getter nem írható ki forrásként +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: nem írható ki forrásként az a tömb, amelynek az elemein kívül tulajdonságai is vannak, nem felsorolható tulajdonsága van, vagy más a prototípusa +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: nem írható ki forrásként az az objektum, amelynek nem felsorolható tulajdonsága van, vagy a prototípusa nem Object.prototype +@msg toSource: the source is too long toSource: a forrás túl hosszú +@msg toSource: the value contains itself toSource: az érték önmagát tartalmazza +@msg toSource: the value has too many entries toSource: az érték túl sok elemet tartalmaz +@msg toSource: the value is nested too deep toSource: az érték túl mélyen van egymásba ágyazva +@msg toString is not a function a toString nem függvény +@msg too large for a zip archive: package to a folder túl nagy egy zip-archívumhoz: csomagolj mappába +@msg too many Shaper edits túl sok Alakformáló-szerkesztés +@msg too many `points` (at most 100000) túl sok `points` elem (legfeljebb 100000) +@msg too many arguments túl sok argumentum +@msg too many characters: at most {MAX_CHARS} túl sok karakter: legfeljebb {MAX_CHARS} +@msg too many dots for this size: lower the frequency túl sok raszterpont ehhez a mérethez: csökkentsd a vonalsűrűséget +@msg too many filters over one file túl sok szűrő egyetlen fájlon +@msg too many overlapping paths for Shaper túl sok egymást átfedő útvonal az Alakformálóhoz +@msg too many quantifiers túl sok kvantor +@msg too many saved clips túl sok mentett vágóútvonal +@msg too many saved graphics states túl sok mentett grafikus állapot +@msg toolbar (a toolbar's name) must be a string toolbar (egy eszköztár neve): karakterlánc kell +@msg toolbar name can't be empty az eszköztár neve nem lehet üres +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (ez az alapértelmezés) vagy "omit" +@msg two saved selections would be named `{_1}` két mentett kijelölés neve lenne ugyanaz: `{_1}` +@msg type dimension cannot be represented with this text transform a szövegméret nem ábrázolható ezzel a szövegtranszformációval +@msg type dimension must be finite a szövegméret véges érték kell legyen +@msg unexpected resources váratlan erőforrások +@msg unknown CMYK profile `{name}` ismeretlen CMYK-profil: `{name}` +@msg unknown File Info field `{k}` ismeretlen fájlinformációs mező: `{k}` +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) ismeretlen OpenType-funkció: `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; a - előtag kikapcsolja; a swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom és aalt egy alternatíva számát `tag=N` alakban fogadja, ahol N 0 és {_1} között lehet, például `salt=2`) +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) ismeretlen OpenType-funkció a featureChanges értékben: `{t}` (a `features` által fogadott címkék használhatók) +@msg unknown PDF preset `{name}` (presets: {_1}) ismeretlen PDF-előbeállítás: `{name}` (előbeállítások: {_1}) +@msg unknown Warp style ismeretlen hajlítási stílus +@msg unknown alignToPath `{a}` ismeretlen alignToPath: `{a}` +@msg unknown arrowhead {v} ismeretlen nyílhegy: {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) ismeretlen artboardLayout kulcs: `{k}` (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` ismeretlen attribútum: `{attribute}` +@msg unknown attribute `{k}` ismeretlen attribútum: `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) ismeretlen tengely: `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` ismeretlen keverési mód: `{b}` +@msg unknown blend mode {b} ismeretlen keverési mód: {b} +@msg unknown brush type `{o}` ismeretlen ecsettípus: `{o}` +@msg unknown category `{c}` ismeretlen kategória: `{c}` +@msg unknown category `{c}` ({_1}) ismeretlen kategória: `{c}` ({_1}) +@msg unknown character `{c}` ismeretlen karakter: `{c}` +@msg unknown character style number field ismeretlen karakterstílus-számmező +@msg unknown dither `{d}` (diffusion, pattern, noise or none) ismeretlen dither: `{d}` (diffusion, pattern, noise vagy none) +@msg unknown effect `{e}` ismeretlen effektus: `{e}` +@msg unknown end {e} ismeretlen end: {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) ismeretlen flattenerPreset: `{name}` (High Resolution, Medium Resolution, Low Resolution vagy egy mentett előbeállítás: lásd: flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) ismeretlen formátum: `{f}` (vcswatches, gpl, ase vagy css) +@msg unknown from {f} ismeretlen from: {f} +@msg unknown graph type `{t}` ismeretlen diagramtípus: `{t}` +@msg unknown grid colours `{name}` (one of {_1}) ismeretlen rácsszínek: `{name}` (ezek egyike kell: {_1}) +@msg unknown gridType `{t}` ismeretlen gridType: `{t}` +@msg unknown highlight `{v}` ismeretlen highlight: `{v}` +@msg unknown intent `{i}` ismeretlen intent: `{i}` +@msg unknown issue kind {id} ismeretlen problémafajta: {id} +@msg unknown issue kind {v} ismeretlen problémafajta: {v} +@msg unknown knockout state {k} ismeretlen kiütési állapot: {k} +@msg unknown language `{language}` (one of {_1}) ismeretlen nyelv: `{language}` (ezek egyike kell: {_1}) +@msg unknown language `{l}` (one of {_1}) ismeretlen nyelv: `{l}` (ezek egyike kell: {_1}) +@msg unknown method `{m}` ({_1}) ismeretlen módszer: `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) ismeretlen mód: `{other}` (gray, rgb, hsb, lab, cmyk vagy web) +@msg unknown mode `{other}` (mono|cmyk) ismeretlen mód: `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) ismeretlen mód: `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) ismeretlen mód: `{other}` (set, add, subtract) +@msg unknown mode `{o}` ismeretlen mód: `{o}` +@msg unknown mode {m} ismeretlen mód: {m} +@msg unknown model `{other}` ismeretlen modell: `{other}` +@msg unknown model `{other}` (keep, new or document) ismeretlen modell: `{other}` (keep, new vagy document) +@msg unknown op {o} ismeretlen op: {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back ismeretlen order: `{o}`; toggle, front, forward, backward vagy back lehet +@msg unknown overprints `{v}` (preserve, simulate or discard) ismeretlen overprints: `{v}` (preserve, simulate vagy discard) +@msg unknown panel ismeretlen panel +@msg unknown panel `{panel}` ismeretlen panel: `{panel}` +@msg unknown parameter `{key}` ismeretlen paraméter: `{key}` +@msg unknown parameter `{within}settings.{format}`; {why} ismeretlen paraméter: `{within}settings.{format}`; {why} +@msg unknown parameter `{within}{key}`; {why} ismeretlen paraméter: `{within}{key}`; {why} +@msg unknown pieLegend `{v}` (none, standard or wedges) ismeretlen pieLegend: `{v}` (none, standard vagy wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) ismeretlen piePosition: `{v}` (even, ratio vagy stacked) +@msg unknown pieSort `{v}` (none, all or first) ismeretlen pieSort: `{v}` (none, all vagy first) +@msg unknown plate `{n}` ismeretlen színkivonat: `{n}` +@msg unknown preference `{k}` ismeretlen beállítás: `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) ismeretlen profil: {id} (lásd: stroke.widthProfile.list) +@msg unknown proof target `{t}` ismeretlen proofcél: `{t}` +@msg unknown quotes key `{k}` (double, single) ismeretlen quotes kulcs: `{k}` (double, single) +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) ismeretlen színcsökkentés: `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform vagy document) +@msg unknown resolution `{v}` ismeretlen felbontás: `{v}` +@msg unknown rule `{rule}` ({_1}) ismeretlen szabály: `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) ismeretlen szabály: `{r}` (lásd: color.harmony) +@msg unknown setting `{k}` ismeretlen beállítás: `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) ismeretlen alakzat: `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) ismeretlen spacing: `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) ismeretlen tileType: `{t}` ({_1}) +@msg unknown units ismeretlen mértékegység +@msg unknown units `{u}` ismeretlen mértékegység: `{u}` +@msg unknown value axis `{v}` (left, right or both) ismeretlen értéktengely: `{v}` (left, right vagy both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) ismeretlen variáció: `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` ismeretlen hajlítási stílus: `{s}` +@msg unknown {key} `{v}` (none, short or full) ismeretlen {key}: `{v}` (none, short vagy full) +@msg unknown {key} profile `{n}` ismeretlen {key} profil: `{n}` +@msg unknown {k} key `{key}` (size, position) ismeretlen {k} kulcs: `{key}` (size, position) +@msg unmatched ')' pár nélküli ')' +@msg unreadable object olvashatatlan objektum +@msg unsafe destination file name nem biztonságos célfájlnév +@msg unterminated character class lezáratlan karakterosztály +@msg unterminated group lezáratlan csoport +@msg use paths or compound paths útvonalakat vagy összetett útvonalakat használj +@msg useArtboards must be true or false, not {v} useArtboards: true vagy false kell, ez nem az: {v} +@msg value for `{var}` must match its variable kind a(z) `{var}` értékének meg kell felelnie a változó típusának +@msg variable `{_1}` is already defined a változó már definiálva van: `{_1}` +@msg variable `{name}` has no `trait` a(z) `{name}` változónak nincs `trait` értéke +@msg variable `{name}` is already defined a változó már definiálva van: `{name}` +@msg variable `{name}` is declared twice a változó kétszer van deklarálva: `{name}` +@msg variable `{to}` already exists már van ilyen változó: `{to}` +@msg view is required a view kötelező +@msg view must be one of {_1} view: ezek egyike kell: {_1} +@msg warpStyle can only edit an applied Warp effect warpStyle: csak már alkalmazott Hajlítás effektust szerkeszthet +@msg width and height must be more than 0 and at most {limit} width és height: 0-nál nagyobb és legfeljebb {limit} érték kell +@msg width factors must be finite a szélességszorzók véges értékek legyenek +@msg width must be positive width: pozitív érték kell +@msg width out of range a szélesség tartományon kívül esik +@msg width/height must be > 0 width/height: > 0 érték kell +@msg wordGap and lineGap need area type in the selection wordGap és lineGap: területszöveg kell a kijelölésben +@msg x and y must be finite numbers x és y: véges számok kellenek +@msg x and y must be given together x és y: együtt kell megadni +@msg xpath needs its variables as an object xpath: a változóit objektumként kell megadni +@msg xpath needs the expression as a string xpath: a kifejezést karakterláncként kell megadni +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone: anchor is kell hozzá; dokkolj egy panel mellé az anchor értékkel, vagy új dokkoszlopba a side értékkel (Start vagy End) +@msg zoom must be positive zoom: pozitív érték kell +@msg {C}: no effect at index {list} {C}: nincs effektus ennél az indexnél: {list} +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} védett, megmarad +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} védett: mentsd a beállításokat más néven +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}, a beállításai és a gyorsítótára pedig nem helyezhetők vissza innen: {_1}, ide: {_2}: {e} +@msg {LABEL} produced an empty result {LABEL}: az eredmény üres +@msg {_1} MB of document, at most {_2} MB a dokumentum {_1} MB, legfeljebb {_2} MB lehet +@msg {_1} MB of images, at most {_2} MB a képek összesen {_1} MB, legfeljebb {_2} MB lehet +@msg {_1} bytes of RGBA data for a {width} × {height} image {_1} bájt RGBA-adat egy {width} × {height} méretű képhez +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1}: nem olvasható, vagy a beolvasása óta megváltozott; előbb csatold újra vagy frissítsd +@msg {_1} can't go into {into} {_1}: nem kerülhet ide: {into} +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1}: nem korlátozhatja a nyomtatást kis felbontásra; válassz PDF 1.4 vagy újabb verziót, vagy engedélyezd a nagy felbontású nyomtatást +@msg {_1} files can't be opened yet ezek a fájlok még nem nyithatók meg: {_1} +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1} fájlok nem védhetők jelszóval: távolítsd el a jelszavakat, vagy ne válassz szabványt +@msg {_1} files can't be {_2} {_1} fájlokra ez nem lehetséges: {_2} +@msg {_1} files can't carry editing data: turn off preserveEditing {_1} fájlok nem tartalmazhatnak szerkesztési adatokat: kapcsold ki a preserveEditing beállítást +@msg {_1} files can't have PDF layers: turn off createLayers {_1} fájlokban nem lehetnek PDF-rétegek: kapcsold ki a createLayers beállítást +@msg {_1} files need a creation date, and this platform has no clock to give one {_1} fájlokhoz létrehozási dátum kell, ezen a platformon pedig nincs óra, amely megadná +@msg {_1} is not a folder: {e} nem mappa: {_1}: {e} +@msg {_1} isn't a path nem útvonal: {_1} +@msg {_1} options: {e} {_1} beállításai: {e} +@msg {_1} paints nothing to rasterize {_1}: semmi raszterizálhatót nem fest +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1}: a jelszavak legfeljebb {MAX_LEGACY_PASSWORD} ASCII-karakterből állhatnak (a PDF 1.7 és az újabb verziók bármilyet elfogadnak) +@msg {_1} with linked images or an image map writes several files: use document.export {_1}: csatolt képekkel vagy képtérképpel több fájlt ír ki; használd a document.export parancsot +@msg {_1} writes one file per artboard here: use document.export, or name one artboard {_1}: itt rajztáblánként egy fájlt ír ki; használd a document.export parancsot, vagy nevezz meg egy rajztáblát +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} képpont túl nagy ehhez: {_3} (oldalanként legfeljebb {max} képpont); csökkentsd a léptéket +@msg {_1}: belongs to another account {_1}: másik fiókhoz tartozik +@msg {_1}: not a folder of its own {_1}: nem saját mappa +@msg {_1}: not a plain file {_1}: nem egyszerű fájl +@msg {_1}: {_2} and {_3} are in the way {_1}: útban van: {_2} és {_3} +@msg {code}: {0} {code}: {0} +@msg {dest}: {e} {dest}: {e} +@msg {dir}: {e} {dir}: {e} +@msg {doc} has no folder nincs mappája: {doc} +@msg {file} isn't linked nincs csatolva: {file} +@msg {file} names more than one linked JavaScript file {file}: egynél több csatolt JavaScript-fájlt nevez meg +@msg {folder} is not a package folder nem csomagmappa: {folder} +@msg {folder}: more than {max} files and folders {folder}: több mint {max} fájl és mappa +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group nem Élő festés csoport: {g} +@msg {id} is not a slice nem szelet: {id} +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key}: 0–{MAX_ADD_AROUND} pt kell +@msg {key} must be [x,y] {key}: [x,y] kell +@msg {key} must be a percentage {min}..100 {key}: százalék kell, {min}..100 +@msg {key} must be a positive number, not {n} {key}: pozitív szám kell, ez nem az: {n} +@msg {key} must be a positive number, not {v} {key}: pozitív szám kell, ez nem az: {v} +@msg {key} must be one of {_1} {key}: ezek egyike kell: {_1} +@msg {key} must be {_1}, got {s} {key}: ez kell: {_1}; kapott érték: {s} +@msg {key} must name a floating panel {key}: lebegő panelt kell megneveznie +@msg {key} must name a panel in another group {key}: másik csoportban lévő panelt kell megneveznie +@msg {key} must name a panel that shows {key}: látható panelt kell megneveznie +@msg {key} must name a registered panel {key}: regisztrált panelt kell megneveznie +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} fájlokban nincs átlátszóság, a fájlban viszont ez lenne: {what}; előbb lapítsd (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind} képek nem nyithatók meg (lásd: document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}`: nem a választási lehetőségek egyike +@msg {k} has control characters{_1} {k}: vezérlőkaraktereket tartalmaz{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k}: túl hosszú (legfeljebb {MAX_TEXT} karakter) +@msg {k} must be > 0 {k}: > 0 érték kell +@msg {k} must be a number (pt) or a length such as "210 mm" {k}: szám (pt) vagy hosszúság kell, például "210 mm" +@msg {k} must be a number from {min} to {max} {k}: {min} és {max} közötti szám kell +@msg {k} must be a number or "auto" {k}: szám vagy "auto" kell +@msg {k} must be a string {k}: karakterlánc kell +@msg {k} must be text {k}: szöveg kell +@msg {k} must be true or false {k}: true vagy false kell +@msg {k} must be {{size?, position?}} {k}: {{size?, position?}} kell +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: az egyéni nyílhegy útvonalának kitölthető terület kell, legfeljebb {most} horgonyponttal és {most} részútvonallal, és minden horgonypont legfeljebb {far} nyílhegyegységre lehet a csúcsától +@msg {k}: bad custom arrowhead path: {e} {k}: hibás egyéni nyílhegyútvonal: {e} +@msg {k}: bad custom arrowhead: {e} {k}: hibás egyéni nyílhegy: {e} +@msg {k}: can't read `{s}` as a length {k}: nem olvasható hosszúságként: `{s}` +@msg {k}: custom must be a name {k}: custom: név kell +@msg {label} must be a number at most {max} {label}: legfeljebb {max} értékű szám kell +@msg {label} must be a number from -1296 to 1296 {label}: -1296 és 1296 közötti szám kell +@msg {label} must be a number from 1 to 10000 {label}: 1 és 10000 közötti szám kell +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name}: beépített elem, nem törölhető +@msg {name} must be a number {name}: szám kell +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name}: {lo}–{hi}{unit} kell (kapott érték: {v}) +@msg {name}: CRC or size mismatch {name}: a CRC vagy a méret nem egyezik +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható; a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt, vagy az elhelyezés előtt exportálj SVG-t vagy PDF-et az Affinityből +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; a beágyazott előnézete sem olvasható ({e}) +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: a copy csak akkor fogad DCS-fájlt, ha a színkivonatai benne vannak; ott csatold újra, ahol a színkivonatfájljai vannak +@msg {path}: larger than {_1} MB {path}: nagyobb, mint {_1} MB +@msg {shown} is not an XPath 1.0 function nem XPath 1.0-függvény: {shown} +@msg {std} files are CMYK, and the file would have colours in {_1} {std} fájlok CMYK színmódúak, a fájlban viszont ilyen színek is lennének: {_1} +@msg {std} files embed their fonts, and the file would have one that isn't {std} fájlok beágyazzák a betűtípusaikat, a fájlban viszont lenne olyan, amely nincs beágyazva +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target}: ebből a fájlból lett beolvasva ez a dokumentum, és a beolvasás során egyes részek kimaradtak ({what}): a felülírásával ezek végleg elvesznének. Írj inkább másik fájlt, vagy add meg az acknowledgeLoss: true értéket, ha mégis le akarod cserélni +@msg {ty} brushes need selected art (or params.{k}) {ty} ecsetekhez kijelölt grafika kell (vagy params.{k}) +@msg {v} is not an asset id nem elemazonosító: {v} +@msg {v} names no layer or sublayer {v}: nem nevez meg réteget vagy alréteget +@msg {what} can't be intertwined nem fonható össze: {what} +@msg {what} doesn't fit the paper with its bleed and marks {what}: a kifutóval és a jelekkel együtt nem fér el a papíron +@msg {what} is not a function nem függvény: {what} +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what}: egy ívre több mint {MAX_COPIES_PER_SHEET} példány kerülne; növeld a méretét +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}: több mint {MAX_TILES} csempére kerülne nyomtatásra; csökkentsd a méretét +@msg {what} {i} out of range {what} {i}: tartományon kívül esik +@msg `{key}` must be from 0.01 to 1000 pt `{key}`: 0.01 és 1000 pt közötti érték kell +@msg select the banner, or mark every artboard jelöld ki a molinót, vagy jelöld meg az összes rajztáblát +@msg the banner has no width or height a molinónak nincs szélessége vagy magassága +@msg the grommet inset is too large for the banner a ringlik széltől mért távolsága túl nagy a molinóhoz +@msg the hem is too deep for the banner a szegés túl mély a molinóhoz +@msg the spacing makes more than {MAX_GROMMETS} grommets ezzel a térközzel több mint {MAX_GROMMETS} ringli keletkezne +@msg there is no banner to mark nincs megjelölhető molinó +@msg turn on at least one side kapcsolj be legalább egy oldalt +@msg with no hem and no grommets there is nothing to mark szegés és ringlik nélkül nincs mit megjelölni +@msg {saving} failed: {e} {saving}: sikertelen: {e} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index cd8dc33f30..43e94d787c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -55,7 +55,7 @@ plus review. Native-speaker review is human work and isn't in the hours. | Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full | | Russian | `ru` | 4,009 / 4,020 | full | | Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full | -| Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | full | +| Hungarian | `hu` | 9,166 rows, 3,444 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it, and lists 47 menu strings, 25 `tl!` strings, 11 undo labels and 45 swatch names without a row; one plural form | full | | Polish | `pl` | 5,130 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/few/many plurals | full; native review pending | | Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending | | Czech | `cs` | 5,240 catalog entries, including 795 `@msg` rows (counted 2026-10-21); menus, literals and messages complete when counted | catalog complete; render-site gaps reduced; native review pending | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f3707717e6..20b3bf5941 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -885,6 +885,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). + - The Hungarian catalog (`crates/ui-egui/src/i18n/hu.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,444 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 40 Hungarian messages: the composite font, justification order and connect end errors that open #7211, #7116 and #7330 translate, and two messages that no catalog has a row for. The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). From 2b64456e5c3531827e722c5af3a3d9bffd64e0d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:01:56 -0800 Subject: [PATCH 09/89] =?UTF-8?q?Smooth=20tool:=20keep=20corners=20where?= =?UTF-8?q?=20the=20path=20turns=20by=20more=20than=2057.5=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Smooth tool refit every anchor in the smoothed stretch with a corner angle of 179°, so a drag across a corner rounded it off. An anchor is now kept as a corner when the path turns there by more than 57.5° (an interior angle under 122.5°) at the anchor itself, over the Smooth radius and over twice the radius on either side. On a closed path the distances are no more than an eighth of its length. The stretch is cut at each corner, each part is refit on its own, and the corner keeps its position. The threshold is the same on every pass and has no setting. A jag or bump smaller than the radius is not kept: the path does not turn by that much over twice the radius. A whole closed path with corners keeps its start anchor when the refit keeps an anchor there. A drag that changes nothing adds no anchors and no undo step. Docs: params text for path.smoothRegion, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/draw2/smooth.rs | 355 +++++++++++++++++++++++- crates/engine/src/tests/draw2.rs | 254 +++++++++++++++++ docs/mcp.md | 7 + docs/target-app-parity.md | 1 + 5 files changed, 609 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 53949fdaf0..0bc8edcc86 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -110,7 +110,7 @@ pub fn specs() -> Vec { "Smooth", [], None, - "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}", + "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag, keeping a corner where the path turns by more than 57.5° at the anchor and over radius and twice radius on either side → {ids, before, after}", has_selection, smooth_region ), diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 560990cec1..a2b4f61bfe 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -13,9 +13,209 @@ use super::{ /// of path within the reach on either side of it. VectorCraft's own value. const SMOOTH_SPREAD: f64 = 1.0 / 3.0; +/// The degrees by which a path must turn at an anchor, and over the tool's reach and twice its reach +/// on either side of it, for the Smooth tool to keep the anchor as a corner ([`smooth_corners`]): a +/// turn of more than 57.5°, an angle inside the corner of less than 122.5°. VectorCraft's own value, +/// the same on every drag and at any Fidelity. +const SMOOTH_CORNER_DEG: f64 = 57.5; + +/// Most pieces a curved segment is cut into evenly, and most more pieces near each of its ends, to +/// measure lengths along it in [`smooth_corners`]. +const CORNER_PIECES: usize = 64; + +/// The control polygon's length of `c`, which is at least its length. +fn polygon_len(c: CubicBez) -> f64 { + c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) +} + +/// The places (0 to 1, in order, without 0 and with 1) on the curved segment `c` that +/// [`smooth_corners`] measures it at: up to [`CORNER_PIECES`] evenly spaced, and near each end, over +/// a stretch at least three times `reach` long where the segment is that long, up to as many more +/// that are no more than `reach` / 8 apart along the control polygon. The turns over `reach` and +/// twice `reach` from an anchor are then measured along the curve however long the segment is. +fn corner_places(c: CubicBez, reach: f64) -> Vec { + let piece = reach / 8.0; + let pieces = |len: f64| if piece > 0.0 { (len / piece).ceil().clamp(1.0, CORNER_PIECES as f64) as usize } else { 1 }; + let k = pieces(polygon_len(c)).max(4); + let mut places: Vec = (1..=k).map(|j| j as f64 / k as f64).collect(); + if piece > 0.0 { + for from_start in [true, false] { + // The stretch from the end: halved until its control polygon is at most six reaches long. + let mut t = 1.0 / k as f64; + let part = |t: f64| if from_start { c.subsegment(0.0..t) } else { c.subsegment(1.0 - t..1.0) }; + for _ in 0..60 { + if polygon_len(part(t)) <= 6.0 * reach { + break; + } + t /= 2.0; + } + let m = pieces(polygon_len(part(t))); + places.extend((1..m).map(|j| t * j as f64 / m as f64).map(|u| if from_start { u } else { 1.0 - u })); + } + } + places.retain(|u| *u > 0.0 && *u <= 1.0); + places.sort_by(f64::total_cmp); + places.dedup(); + places +} + +/// The degrees between the directions `u` and `v`, `None` when either has no length or is not a +/// number. +fn turn_between(u: kurbo::Vec2, v: kurbo::Vec2) -> Option { + let (lu, lv) = (u.hypot(), v.hypot()); + if !(lu.is_finite() && lv.is_finite() && lu > 1e-12 && lv > 1e-12) { + return None; + } + let deg = u.cross(v).atan2(u.dot(v)).abs().to_degrees(); + deg.is_finite().then_some(deg) +} + +/// Which anchors of `anchors` (a closed path when `closed`) the Smooth tool keeps as corners: those +/// where the path turns by more than [`SMOOTH_CORNER_DEG`] at the anchor itself, between the +/// direction it arrives along and the one it leaves along, and also over `reach` and over twice +/// `reach` on either side of it, between the direction from the point that far back along the path +/// to the anchor and the direction from the anchor to the point that far ahead. The points are taken +/// no farther than the ends of an open path, and no more than an eighth of the way round a closed +/// one. A jag or a bump smaller than the reach turns sharply at its anchor but not over twice the +/// reach, and an anchor in the middle of a tight curve turns over the reach but not at the anchor, so +/// neither is kept. An open path's first and last anchors are never corners. +fn smooth_corners(anchors: &[Anchor], closed: bool, reach: f64) -> Vec { + let n = anchors.len(); + let mut corners = vec![false; n]; + if n < 2 || (n < 3 && !closed) { + return corners; + } + // The distances on either side the turn is measured over: one reach and two, no more than an + // eighth of the way round a closed path. Over that much of a circle the path turns by 45°, so a + // loop shorter than the reach is not all corners, while a square's or a triangle's corners still are. + let mut spans = [reach, 2.0 * reach]; + if closed { + let mut total = 0.0; + for i in 0..n { + total += walk(anchors, closed, (i, true), [f64::INFINITY; 2], reach, 1).1; + } + if !(total.is_finite() && total > 0.0) { + return corners; + } + spans = spans.map(|r| r.min(total / 8.0)); + } + for (i, flag) in corners.iter_mut().enumerate() { + if !closed && (i == 0 || i == n - 1) { + continue; + } + let (prev, a, next) = (anchors[(i + n - 1) % n], anchors[i], anchors[(i + 1) % n]); + let arrive = [a.p - a.h_in, a.p - prev.h_out, a.p - prev.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default(); + let leave = [a.h_out - a.p, next.h_in - a.p, next.p - a.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default(); + if !turn_between(arrive, leave).is_some_and(|t| t > SMOOTH_CORNER_DEG) { + continue; + } + let [Some(back_near), Some(back_far)] = walk(anchors, closed, (i, false), spans, reach, MAX_CORNER_WALK).0 else { continue }; + let [Some(ahead_near), Some(ahead_far)] = walk(anchors, closed, (i, true), spans, reach, MAX_CORNER_WALK).0 else { continue }; + *flag = [(back_near, ahead_near, spans[0]), (back_far, ahead_far, spans[1])] + .into_iter() + .all(|(back, ahead, r)| r <= 0.0 || turn_between(a.p - back, ahead - a.p).is_some_and(|t| t > SMOOTH_CORNER_DEG)); + } + corners +} + +/// Most segments [`walk`] passes from an anchor before it stops, which bounds the work for each +/// anchor on a path of very many very short segments. +const MAX_CORNER_WALK: usize = 256; + +/// A walk along the path `anchors` from anchor `from.0`, ahead when `from.1` and back otherwise, +/// past no more than `segments` segments, measured along the lines between each curved segment's +/// points at [`corner_places`]. It gives the points at the distances `to` (in order, the nearer +/// first), an open path's end for one beyond the end, and `None` for one it did not reach within +/// `segments` segments, and the length it walked. Both points are `None` when it meets a length that +/// is not a number or is too large to add, and the length is then infinite. +fn walk(anchors: &[Anchor], closed: bool, from: (usize, bool), to: [f64; 2], reach: f64, segments: usize) -> ([Option; 2], f64) { + let (n, forward) = (anchors.len(), from.1); + let Some(mut here) = anchors.get(from.0).map(|a| a.p) else { return ([None; 2], 0.0) }; + let (mut at, mut gone) = (from.0, 0.0); + let mut found: [Option; 2] = [None; 2]; + let mut at_end = false; + 'segments: for _ in 0..segments { + let next = match (forward, closed) { + (true, _) if at + 1 < n => at + 1, + (true, true) => 0, + (false, _) if at > 0 => at - 1, + (false, true) => n - 1, + _ => { + at_end = true; + break; + } + }; + let (a, b) = (anchors[at], anchors[next]); + let c = if forward { CubicBez::new(a.p, a.h_out, b.h_in, b.p) } else { CubicBez::new(a.p, a.h_in, b.h_out, b.p) }; + let places = if c.p1 == c.p0 && c.p2 == c.p3 { vec![1.0] } else { corner_places(c, reach) }; + for u in places { + let q = if u >= 1.0 { c.p3 } else { c.eval(u) }; + let d = here.distance(q); + if !(d.is_finite() && (gone + d).is_finite()) { + return ([None; 2], f64::INFINITY); + } + for (slot, &dist) in found.iter_mut().zip(&to) { + if slot.is_none() && gone + d >= dist { + let t = if d > 0.0 { ((dist - gone) / d).clamp(0.0, 1.0) } else { 1.0 }; + *slot = Some(here.lerp(q, t)); + } + } + gone += d; + here = q; + if found.iter().all(Option::is_some) { + break 'segments; + } + } + at = next; + } + // An open path that ends after the last segment walked ends there too. + if !closed && at == if forward { n - 1 } else { 0 } { + at_end = true; + } + (found.map(|p| p.or(at_end.then_some(here))), gone) +} + +/// Whether `a` and `b` are the same anchors, to within a rounding error. +fn same_anchors(a: &[Anchor], b: &[Anchor]) -> bool { + let near = |p: Point, q: Point| p.distance(q) <= 1e-9; + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out)) +} + +/// [`refit_run`] of the open run `anchors` cut at its anchors marked in `corners`: each part between +/// two corners is smoothed and refitted on its own, so each corner keeps its place and the direction +/// the path arrives and leaves along. `free` applies to the run's first and last anchors only. +fn refit_split(anchors: &[Anchor], corners: &[bool], tol: f64, sigma: f64, free: [bool; 2]) -> Vec { + let last = anchors.len().saturating_sub(1); + let mut cuts: Vec = (1..last).filter(|&i| corners.get(i).copied().unwrap_or(false)).collect(); + if cuts.is_empty() { + return refit_run(anchors, tol, sigma, free); + } + cuts.insert(0, 0); + cuts.push(last); + let mut out: Vec = Vec::with_capacity(anchors.len()); + for w in cuts.windows(2) { + let (a, b) = (w[0], w[1]); + let part = refit_run(&anchors[a..=b], tol, sigma, [free[0] && a == 0, free[1] && b == last]); + match out.pop() { + // The corner ends one part and starts the next: it keeps its place, and takes the handle + // in from the part before and the handle out from the part after. + Some(end) => { + let mut rest = part.into_iter(); + if let Some(start) = rest.next() { + out.push(Anchor::with_handles(anchors[a].p, end.h_in, start.h_out)); + } + out.extend(rest); + } + None => out.extend(part), + } + } + out +} + /// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of /// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the -/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether +/// smoothing moved it. The tool cuts the path at its corners first ([`smooth_corners`]), so within a +/// part the refit keeps a corner only where the path turns back on itself. `free` says whether /// the start and the end of the stretch refitted are ends of an open path, whose direction then /// follows the smoothed path within the reach rather than the first or last segment. fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions { @@ -239,9 +439,33 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f } if sp.closed { let Some(k) = marked.iter().position(|m| !m) else { - // Everything is in range: smooth the whole closed path, across its start too. - let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2])); - return f.subpaths.into_iter().next(); + let corners = smooth_corners(&sp.anchors, true, radius); + let Some(c) = corners.iter().position(|c| *c) else { + // Everything is in range and nothing is a corner: smooth the whole closed path, + // across its start too. + let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2])); + return f.subpaths.into_iter().next(); + }; + // Cut the path open at a corner, which then ends the last part as well as starting the first. + let mut open = sp.anchors.clone(); + open.rotate_left(c); + let mut cut = corners; + cut.rotate_left(c); + open.push(open[0]); + cut.push(true); + let mut res = refit_split(&open, &cut, tol, sigma, [false; 2]); + if same_anchors(&res, &open) { + return None; + } + let l = res.pop()?; + if let Some(r0) = res.first_mut() { + *r0 = Anchor::with_handles(r0.p, l.h_in, r0.h_out); + } + // The path starts where it did when the refit keeps an anchor there. + if let Some(j) = res.iter().position(|a| a.p == sp.anchors[0].p) { + res.rotate_left(j); + } + return Some(SubPath::new(res, true)); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. let mut open = sp.clone(); @@ -251,14 +475,18 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f open.anchors.push(open.anchors[0]); m.push(false); open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false); + let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, radius, false); + if same_anchors(&res, &open.anchors) { + return None; + } let l = res.pop()?; if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; } return Some(SubPath::new(res, true)); } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false)) + let res = smooth_open(&sp.anchors, &marked, near, tol, sigma, radius, true); + (!same_anchors(&res, &sp.anchors)).then(|| SubPath::new(res, false)) } /// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it @@ -292,8 +520,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez /// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next /// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first /// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends, -/// and those of the open run a closed path is cut into, keep their direction. -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec { +/// and those of the open run a closed path is cut into, keep their direction. Each run keeps its +/// corners ([`smooth_corners`] within `radius`, the tool's reach, measured within the run), and a run +/// whose refit changes nothing stays as it was. +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, radius: f64, open: bool) -> Vec { let n = anchors.len(); let mut runs = vec![]; let mut i = 0; @@ -334,7 +564,11 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); } let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()]; - let new = refit_run(&piece, tol, sigma, free); + let corners = smooth_corners(&piece, false, radius); + let new = refit_split(&piece, &corners, tol, sigma, free); + if same_anchors(&new, &piece) { + continue; + } out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); } out @@ -649,4 +883,107 @@ mod tests { assert_eq!(drag_stretches(&one), vec![0]); assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12); } + + fn polyline(pts: &[(f64, f64)]) -> Vec { + pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect() + } + + /// Anchors on one point, anchors that are not numbers or are too far out to measure, and a + /// zigzag of 100,001 anchors 1 pt high give no corner and do not fail; a square's four anchors + /// are its corners, and an open path's ends never are. + #[test] + fn corners_are_found_only_where_the_path_turns_at_and_around_an_anchor() { + assert_eq!(smooth_corners(&polyline(&[(5.0, 5.0); 4]), false, 12.0), vec![false; 4]); + assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (f64::NAN, 1.0), (10.0, 0.0)]), false, 12.0), vec![false; 3]); + assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (1e308, 0.0), (1e308, 1e308), (-1e308, 0.0)]), true, 12.0), vec![false; 4]); + let zigzag: Vec<(f64, f64)> = (0..=100_000).map(|i| (f64::from(i), f64::from(i % 2))).collect(); + assert!(!smooth_corners(&polyline(&zigzag), false, 12.0).contains(&true)); + let square = polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + assert_eq!(smooth_corners(&square, true, 12.0), vec![true; 4]); + assert_eq!(smooth_corners(&square, false, 12.0), vec![false, true, true, false]); + assert_eq!(smooth_corners(&square, false, 0.0), vec![false, true, true, false], "with no reach the turn at the anchor decides"); + assert_eq!(smooth_corners(&square[..2], false, 12.0), vec![false; 2]); + } + + /// A 90° bump 400 pt high cut into 1 pt segments, between two long straight segments: with a reach + /// of 10 pt its top is a corner, and with a reach of 1,000 pt, where the walk passes more segments + /// than it measures before it gets that far, its top is not one, as the path turns by about 51° + /// over that reach. + #[test] + fn a_corner_is_not_kept_where_the_reach_is_not_measured() { + let side = 400.0 * std::f64::consts::SQRT_2; + let steps = side.ceil() as usize; + let mut pts = vec![(-3000.0, 0.0)]; + pts.extend((0..steps).map(|j| { + let f = j as f64 / steps as f64; + (-400.0 + 400.0 * f, 400.0 * f) + })); + pts.extend((0..=steps).map(|j| { + let f = j as f64 / steps as f64; + (400.0 * f, 400.0 - 400.0 * f) + })); + pts.push((3000.0, 0.0)); + let anchors = polyline(&pts); + let top = steps + 1; + assert_eq!(anchors[top].p, Point::new(0.0, 400.0)); + assert!(smooth_corners(&anchors, false, 10.0)[top]); + assert!(!smooth_corners(&anchors, false, 1000.0)[top]); + } + + /// A 60° corner between two curved segments 1,000 pt long is found at a reach of 2 pt as it is + /// when each segment is cut into a thousand pieces: the turns over the reach are measured along the + /// curve near the anchor, not along a chord much longer than the reach. + #[test] + fn a_corner_between_long_curves_is_measured_along_them() { + // The curves arrive along x and leave at 60° to it, each bending away from the other. + let d = kurbo::Vec2::from_angle(-60f64.to_radians()); + let side = kurbo::Vec2::new(-d.y, d.x) * 1000.0; + let v = Point::new(0.0, 0.0); + let a = Anchor::with_handles(Point::new(-1000.0, 1000.0), Point::new(-1000.0, 1000.0), Point::new(-300.0, 1000.0)); + let b = Anchor::with_handles(v, Point::new(-300.0, 0.0), v + d * 300.0); + let end = v + d * 1000.0 + side; + let c = Anchor::with_handles(end, v + d * 300.0 + side, end); + let long = [a, b, c]; + let fine: Vec = { + let sp = SubPath::new(long.to_vec(), false); + let mut pts = vec![]; + for i in 0..2 { + let s = sp.segment(i); + for j in 0..1000 { + let p = s.subsegment(f64::from(j) / 1000.0..f64::from(j + 1) / 1000.0); + if j == 0 && i == 0 { + pts.push(Anchor::with_handles(p.p0, p.p0, p.p1)); + } else if let Some(last) = pts.last_mut() { + *last = Anchor::with_handles(last.p, last.h_in, p.p1); + } + pts.push(Anchor::with_handles(p.p3, p.p2, p.p3)); + } + } + pts + }; + let at_v = |anchors: &[Anchor]| { + let corners = smooth_corners(anchors, false, 2.0); + anchors.iter().zip(corners).find(|(x, _)| x.p == v).map(|(_, c)| c) + }; + assert_eq!(at_v(&fine), Some(true), "cut finely, the anchor is a corner"); + assert_eq!(at_v(&long), Some(true)); + } + + /// A jagged closed path whose length is less than twice the reach: its jags are not corners, + /// while a triangle that small keeps its three. + #[test] + fn a_small_jagged_loop_has_no_corners() { + let ring: Vec<(f64, f64)> = (0..16) + .map(|i| { + let (a, r) = (f64::from(i) / 16.0 * std::f64::consts::TAU, if i % 2 == 0 { 3.0 } else { 2.0 }); + (r * a.cos(), r * a.sin()) + }) + .collect(); + let jags = polyline(&ring); + let sp = SubPath::new(jags.clone(), true); + assert!(subpath_len(&sp) < 24.0); + assert!(smooth_corners(&jags, true, 1e-6).iter().all(|c| *c), "each jag turns sharply at its anchor"); + assert_eq!(smooth_corners(&jags, true, 12.0), vec![false; 16]); + assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (6.0, 0.0), (3.0, 5.196)]), true, 12.0), vec![true; 3]); + } } diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 79dbd366f6..ded5ddb265 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1913,6 +1913,260 @@ fn smoothing_leaves_paths_out_of_reach_and_straight_segments_alone() { assert_eq!(path(&s, line), before); } +/// The turn, in degrees, of `sp` at each of its anchors: round a closed path, and 0 at an open +/// path's first and last anchors. +fn smooth_anchor_turns(sp: &vectorcraft_geom::SubPath) -> Vec { + let (n, segs) = (sp.anchors.len(), sp.segment_count()); + (0..n) + .map(|i| { + if segs == 0 || !sp.closed && (i == 0 || i == n - 1) { + return 0.0; + } + let (a, b) = (sp.segment((i + segs - 1) % segs), sp.segment(i % segs)); + let tin = [a.p3 - a.p2, a.p3 - a.p1, a.p3 - a.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default(); + let tout = [b.p1 - b.p0, b.p2 - b.p0, b.p3 - b.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default(); + tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees() + }) + .collect() +} + +/// The turn of `sp` at its anchor on `at`, `None` when no anchor is there. +fn smooth_turn_at(sp: &vectorcraft_geom::SubPath, at: Point) -> Option { + let i = sp.anchors.iter().position(|a| a.p.distance(at) < 1e-9)?; + smooth_anchor_turns(sp).get(i).copied() +} + +/// An open polyline with a corner at (300, 300) whose angle inside is `inside` degrees: a 100 pt +/// leg along y = 300 from the left, then a 100 pt leg turning by 180 - `inside` degrees, each leg cut +/// into `per_leg` segments. Returns the path, the corner, and a drag along the path from 40 pt +/// before the corner to 40 pt after it. +fn smooth_corner_path(s: &mut Session, inside: f64, per_leg: usize) -> (NodeId, Point, Vec) { + let v = Point::new(300.0, 300.0); + let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians()); + let back = kurbo::Vec2::new(-100.0, 0.0); + let legs = per_leg as f64; + let mut pts: Vec = (0..per_leg).map(|k| v + back * (1.0 - k as f64 / legs)).collect(); + pts.extend((0..=per_leg).map(|k| v + d * (100.0 * k as f64 / legs))); + let anchors: Vec<_> = pts.iter().map(|p| json!({"x": p.x, "y": p.y})).collect(); + let id = smooth_test_path(s, &anchors, false); + let mut drag: Vec = (0..8).map(|k| v - kurbo::Vec2::new(40.0 - 5.0 * f64::from(k), 0.0)).collect(); + drag.extend((0..=8).map(|k| v + d * (5.0 * f64::from(k)))); + (id, v, drag) +} + +/// A Smooth drag over a corner keeps it where the path turns there by more than 57.5° (an angle +/// inside the corner under 122.5°), whether each leg is one segment or ten and at any Fidelity. A +/// wider corner is not kept: when the drag changes the path, no anchor is left turning as sharply +/// there, and a leg of ten segments at the default Fidelity always rounds it. +#[test] +fn the_smooth_tool_keeps_corners_sharper_than_its_threshold() { + let mut wrong = vec![]; + for inside in [30.0f64, 60.0, 90.0, 120.0, 122.4, 122.6, 125.0, 135.0, 150.0, 170.0] { + let kept = inside < 122.5; + for per_leg in [1, 10] { + for fidelity in [0.5, 2.5, 10.0] { + let mut s = session(); + let (id, v, drag) = smooth_corner_path(&mut s, inside, per_leg); + let before = path(&s, id); + let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect(); + s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": fidelity})).unwrap(); + let p = path(&s, id); + let turn = 180.0 - inside; + let at = smooth_turn_at(&p.subpaths[0], v); + let changed = p != before; + let rounded = changed && at.is_none_or(|t| t < turn - 1.0); + let right = if kept { at.is_some_and(|t| (t - turn).abs() < 0.01) } else { rounded || !changed && per_leg == 1 }; + let most = smooth_anchor_turns(&p.subpaths[0]).into_iter().fold(0.0, f64::max); + if !right || !kept && most > 57.5 || !kept && per_leg == 10 && fidelity == 2.5 && !rounded { + wrong.push(format!( + "{inside}° inside, {per_leg} segments a leg, Fidelity {fidelity}: turn {at:?}, changed {changed}, sharpest {most}°" + )); + } + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Three Smooth drags over a 90° and a 120° corner leave the corner where it was and as sharp, while +/// the first one smooths a wobble on the same leg. +#[test] +fn smooth_drags_again_and_again_keep_a_corner_in_place() { + for inside in [90.0f64, 120.0] { + let mut s = session(); + let v = Point::new(300.0, 300.0); + let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians()); + let mut anchors: Vec<_> = (0..=18).map(|k| json!({"x": 180.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 301.5 } else { 300.0 }})).collect(); + anchors.push(json!({"x": v.x, "y": v.y})); + let c = v + d * 100.0; + anchors.push(json!({"x": c.x, "y": c.y})); + let id = smooth_test_path(&mut s, &anchors, false); + let mut drag = smooth_drag_along(300.0, 175.0, 300.0); + drag.extend((1..=8).map(|k| v + d * (5.0 * f64::from(k)))); + let mut before = path(&s, id); + for pass in 1..=3 { + smooth_pass(&mut s, &drag); + let p = path(&s, id); + let at = smooth_turn_at(&p.subpaths[0], v); + assert!(at.is_some_and(|t| (t - (180.0 - inside)).abs() < 0.01), "{inside}°, pass {pass}: {at:?}"); + if pass == 1 { + assert!(p.anchor_count() < before.anchor_count(), "{inside}°: the wobble is smoothed: {} anchors", p.anchor_count()); + assert!(total_turning(&p) < total_turning(&before), "{inside}°: the path turns less"); + } + before = p; + } + } +} + +/// A Smooth drag along a line with 1 pt jags, each turning by 90°, smooths them all: none is kept +/// as a corner, since the line hardly turns over the tool's reach. +#[test] +fn smoothing_keeps_no_small_jag_as_a_corner() { + let mut s = session(); + let anchors: Vec<_> = (0..=200).map(|i| json!({"x": 100.0 + f64::from(i), "y": if i % 2 == 1 { 201.0 } else { 200.0 }})).collect(); + let id = smooth_test_path(&mut s, &anchors, false); + assert!(smooth_anchor_turns(&path(&s, id).subpaths[0]).iter().skip(1).take(199).all(|t| (t - 90.0).abs() < 1e-6)); + smooth_pass(&mut s, &smooth_drag_along(200.5, 90.0, 310.0)); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.anchors.len() < 50, "the jags are smoothed: {} anchors", sp.anchors.len()); + let most = smooth_anchor_turns(sp).into_iter().fold(0.0, f64::max); + assert!(most <= 57.5, "a jag is kept, turning {most}°"); +} + +/// A closed path with 90° corners at (200, 200), where it starts, and at (400, 200), 75° ones at +/// (400, 400) and (200, 400), a 30° bend at (300, 426.79), and a top side jagged by 1 pt. With +/// `from_bend` the path starts at the bend instead. +fn smooth_house(s: &mut Session, from_bend: bool) -> (NodeId, [Point; 4], Point) { + let corners = [Point::new(200.0, 200.0), Point::new(400.0, 200.0), Point::new(400.0, 400.0), Point::new(200.0, 400.0)]; + let bend = Point::new(300.0, 400.0 + 100.0 / 75f64.to_radians().tan()); + let mut anchors = vec![json!({"x": 200.0, "y": 200.0})]; + anchors.extend((1..40).map(|k| json!({"x": 200.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 201.0 } else { 200.0 }}))); + for p in [corners[1], corners[2], bend, corners[3]] { + anchors.push(json!({"x": p.x, "y": p.y})); + } + if from_bend { + let at_bend = anchors.len() - 2; + anchors.rotate_left(at_bend); + } + (smooth_test_path(s, &anchors, true), corners, bend) +} + +/// A Smooth drag round the whole of a closed path, and one along its top side only, keep its corners, +/// the one where the path starts included, and smooth the jags; the path still starts where it did, +/// at a corner or at the bend, which the refit keeps, and the bend out of the second drag's reach +/// stays as it was. +#[test] +fn smoothing_a_closed_path_keeps_its_corners() { + let turns: Vec = { + let mut s = session(); + let (id, corners, _) = smooth_house(&mut s, false); + corners.iter().map(|c| smooth_turn_at(&path(&s, id).subpaths[0], *c).unwrap()).collect() + }; + for from_bend in [false, true] { + let mut s = session(); + let (id, corners, bend) = smooth_house(&mut s, from_bend); + let before = path(&s, id); + let mut ring: Vec = vec![]; + for (a, b) in [(corners[0], corners[1]), (corners[1], corners[2]), (corners[2], bend), (bend, corners[3]), (corners[3], corners[0])] { + let n = (a.distance(b) / 5.0).ceil(); + ring.extend((0..n as usize).map(|k| a.lerp(b, k as f64 / n))); + } + ring.push(corners[0]); + smooth_pass(&mut s, &ring); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "{} anchors", sp.anchors.len()); + assert_eq!(sp.anchors[0].p, before.subpaths[0].anchors[0].p, "the path still starts where it did"); + for (c, t) in corners.iter().zip(&turns) { + let at = smooth_turn_at(sp, *c); + assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "the corner at {c:?} turning {t}° is kept: {at:?}"); + } + } + + let mut s = session(); + let (id, corners, bend) = smooth_house(&mut s, false); + let before = path(&s, id); + smooth_pass(&mut s, &smooth_drag_along(200.0, 190.0, 410.0)); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "the top is smoothed: {} anchors", sp.anchors.len()); + for (c, t) in corners.iter().zip(&turns) { + let at = smooth_turn_at(sp, *c); + assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "part of the path: the corner at {c:?} turning {t}° is kept: {at:?}"); + } + assert!(smooth_turn_at(sp, bend).is_some_and(|t| (t - 30.0).abs() < 0.01), "the bend out of reach stays"); +} + +/// A Smooth drag over an anchor whose curved legs meet at a corner keeps the corner, taking the turn +/// from the directions the curves arrive and leave along, a handle of no length included. +#[test] +fn smoothing_keeps_a_corner_between_curves() { + use kurbo::ParamCurve; + let v = Point::new(200.0, 300.0); + let with_in = json!({"x": 200.0, "y": 300.0, "in": [170.0, 270.0], "out": [230.0, 270.0]}); + let no_in = json!({"x": 200.0, "y": 300.0, "out": [230.0, 270.0]}); + for (corner, first_out) in [(with_in, [130.0, 260.0]), (no_in, [170.0, 230.0])] { + let mut s = session(); + let anchors = [json!({"x": 100.0, "y": 300.0, "out": first_out}), corner, json!({"x": 300.0, "y": 300.0, "in": [270.0, 260.0]})]; + let id = smooth_test_path(&mut s, &anchors, false); + let before = path(&s, id); + let turn = smooth_turn_at(&before.subpaths[0], v).unwrap(); + assert!(turn > 80.0, "{turn}"); + let (a, b) = (before.subpaths[0].segment(0), before.subpaths[0].segment(1)); + let mut drag: Vec = (0..=10).map(|k| a.eval(0.6 + 0.04 * f64::from(k))).collect(); + drag.extend((1..=10).map(|k| b.eval(0.04 * f64::from(k)))); + smooth_pass(&mut s, &drag); + let at = smooth_turn_at(&path(&s, id).subpaths[0], v); + assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° is kept: {at:?}"); + } +} + +/// A corner at the first anchor of the stretch a drag smooths, just out of its reach, keeps its +/// place and its turn. +#[test] +fn a_corner_at_the_end_of_the_smoothed_stretch_stays() { + let mut s = session(); + let mut anchors = vec![json!({"x": 100.0, "y": 300.0}), json!({"x": 200.0, "y": 300.0})]; + anchors.extend((1..=20).map(|k| json!({"x": if k % 2 == 1 { 201.0 } else { 200.0 }, "y": 300.0 - 5.0 * f64::from(k)}))); + let id = smooth_test_path(&mut s, &anchors, false); + let before = path(&s, id); + let turn = smooth_turn_at(&before.subpaths[0], Point::new(200.0, 300.0)).unwrap(); + let drag: Vec = (0..=19).map(|k| Point::new(200.0, 287.0 - 5.0 * f64::from(k))).collect(); + smooth_pass(&mut s, &drag); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert_ne!(p, before, "the jagged leg is smoothed"); + assert_eq!(sp.anchors[0], before.subpaths[0].anchors[0]); + let at = smooth_turn_at(sp, Point::new(200.0, 300.0)); + assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° stays: {at:?}"); +} + +/// A Smooth drag over corners it keeps, with nothing else to smooth, changes nothing and adds no +/// undo step: an open path's corner and a square's four corners. +#[test] +fn a_smooth_drag_that_only_meets_corners_adds_no_undo_step() { + let mut s = session(); + let (id, v, drag) = smooth_corner_path(&mut s, 90.0, 1); + let before = path(&s, id); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(smooth_pass_ids(&mut s, &drag), json!([]), "a corner at {v:?}"); + assert_eq!(path(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + + let square = [(100.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y})); + let id = smooth_test_path(&mut s, &square, true); + let before = path(&s, id); + let undo = s.doc().unwrap().history.undo.len(); + let mut ring = smooth_drag_along(100.0, 100.0, 200.0); + ring.extend((1..=20).map(|k| Point::new(200.0, 100.0 + 5.0 * f64::from(k)))); + ring.extend(smooth_drag_along(200.0, 100.0, 200.0).into_iter().rev().skip(1)); + ring.extend((1..=20).map(|k| Point::new(100.0, 200.0 - 5.0 * f64::from(k)))); + assert_eq!(smooth_pass_ids(&mut s, &ring), json!([]), "a square"); + assert_eq!(path(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + #[test] fn join_tool_joins_two_lines() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 84c75a81d0..d30f2bbf8a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5295,6 +5295,13 @@ keeps the segment's direction when the smoothed path there turns back by 90° or path, the curve keeps the direction of the part outside the drag. `object.path.smooth` and Simplify keep each end's direction. +`path.smoothRegion` keeps an anchor as a corner where the path turns by more than 57.5° (an angle inside the corner +under 122.5°) at the anchor and also over `radius` and over twice `radius` on either side of it (on a closed path, no +more than an eighth of its length), so a jag or a bump smaller than `radius` is still smoothed. The stretches on either side of a kept corner are smoothed and refitted on their +own, and the corner keeps its place and the directions the path arrives and leaves along, on every call and at any +`fidelity`. A call that changes no anchor returns `{ids: [], before: 0, after: 0}` and adds no undo step. The 57.5° +threshold is VectorCraft's own value. + In the desktop app, Object › Path › Smooth and the canvas context menu's Smooth… (listed while the selection holds a path) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at the Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..23fd59a117 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1149,6 +1149,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. When a Smooth tool drag reaches the first or last anchor of an open path, that anchor stays where it is and the curve there leaves or arrives along the smoothed path within the reach, so a jagged first or last segment no longer sets the end's direction; an end where the smoothed path turns back by 90° or more keeps its segment's direction, and where the refitted stretch ends inside the path the curve keeps the direction of the part outside the drag. This rule is VectorCraft's own (`tests/draw2.rs`, `smoothing_an_open_path_turns_its_jagged_ends_with_the_path` and `a_smooth_drag_turns_only_the_end_it_reaches`). + A Smooth tool drag keeps a corner where the path turns by more than 57.5° (an angle inside the corner under 122.5°) and rounds a wider one. An anchor is kept as a corner when the path turns by that much at the anchor and also over the drag's reach and over twice the reach on either side of it, so a jag or a bump smaller than the reach is still smoothed; [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes the tool as reducing irregularities in a path. The stretches on either side of a kept corner are smoothed and refitted on their own, and the corner keeps its place and the directions the path arrives and leaves along, on every drag, on open and closed paths, the anchor a closed path starts at included. A drag that changes no anchor adds no undo step. The threshold and the two distances are VectorCraft's own values, the same on every drag and at any Fidelity (`tests/draw2.rs`, `the_smooth_tool_keeps_corners_sharper_than_its_threshold`, `smoothing_a_closed_path_keeps_its_corners` and `a_smooth_drag_that_only_meets_corners_adds_no_undo_step`). A Smooth drag along a curved segment smooths the stretch of it in the drag's reach even when neither of the segment's anchors is in reach, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes dragging along the path segment; the segment is cut where the drag enters and leaves the reach, and the parts outside keep their shape (`path.smoothRegion`). Paths of two anchors are smoothed too, and Alt with the Paintbrush smooths a selected two-anchor curve; a two-anchor straight line still gets the closed stroke. How the reach is found on a segment (samples no more than half the reach apart) is VectorCraft's own, and a straight segment is left as it is. Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). Keep selected in the Pencil and Paintbrush Tool Options is on by default: while it is on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`path.freehand` `keepSelected`). From 4819fdbc0a6bf011aaebeb32856d875e0cb35f33 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:45:29 -0800 Subject: [PATCH 10/89] Type: Justification, OpenType, text styles and Convert to Text change every frame of a selected story With the Selection tool, the Justification dialog, the OpenType panel, a Character or Paragraph Styles row (Alt-click and Clear Overrides included) and Type > Bullets and Numbering > Convert to Text now change every frame of each selected threaded story, locked and hidden frames included, in one undo step. Before, they changed only the selected frames. - The Justification dialog keeps the frames of the stories selected when it opened for its preview and OK. - The OpenType panel's Figure menu shows Mixed and its checkboxes show a dash when the characters differ; a click on a dash turns the feature on everywhere. - The style panels highlight a style only when all of the text has it, and show "+" when any of it overrides the style. - Convert to Text takes the same stories as Apply Bullets and Apply Numbers. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/dialogs/justification.rs | 77 +++++++++- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/opentype.rs | 145 +++++++++++++++--- crates/ui-egui/src/panels/paragraph.rs | 21 +-- crates/ui-egui/src/panels/paragraph/tests.rs | 44 ++++++ crates/ui-egui/src/panels/text_styles.rs | 63 +++++--- .../ui-egui/src/panels/text_styles/tests.rs | 74 ++++++++- docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + 10 files changed, 371 insertions(+), 62 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e3b57166b7..9eb39e056f 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -260,7 +260,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "type.convertListToText", "Convert to Text", "", - "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", + "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs, in every frame of a threaded story (or of those the Type tool's selection touches), become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", ), ( "align.horizontalLeft", diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index dc42dcff87..168f5cfda1 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -9,8 +9,10 @@ //! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`, //! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading` //! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`, -//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on) and, -//! after Select All in threaded text, `__story` (the edited frame, whose story it acts on). +//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on; with +//! the Selection tool, every frame of each story selected when the dialog opened, locked and hidden +//! frames included) and, after Select All in threaded text, `__story` (the edited frame, whose +//! story it acts on). //! //! Behaviour reference (public documentation): //! . @@ -95,6 +97,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result { targets = json!({"ids": [id.0], "start": a, "end": b}); // After Select All in threaded text: every paragraph of every frame ([`params`]). story = crate::panels::character::text_story(app).map(|_| id.0); + } else { + // With the Selection tool: every frame of each selected story. + crate::panels::character::target_selected_stories(app, &mut targets); } let mut values = Map::new(); for (_, prefix, _) in ROWS { @@ -423,6 +428,74 @@ mod tests { } } + /// A threaded story of three frames, its second frame locked and its third hidden, a type object + /// of its own, and the first frame selected with the Selection tool → the app, the frames and + /// the other type object. + fn story_selected_in_its_first_frame() -> (VectorcraftApp, [u64; 3], u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + (app, [a, b, c], other) + } + + /// Every paragraph's Auto Leading in `id`. + fn auto_leadings(app: &VectorcraftApp, id: u64) -> Vec { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justification.auto_leading).collect(), + _ => panic!("not text"), + } + } + + /// With the Selection tool a selected frame of threaded text stands for its whole story: the + /// dialog opens blank where a locked or hidden frame differs, previews on every frame, Cancel + /// restores them, and OK changes every frame of the story as it was when the dialog opened, in + /// one undo step, leaving other type and a selection made later alone. + #[test] + fn selection_tool_justification_reaches_every_frame_of_the_story() { + let (mut app, frames, other) = story_selected_in_its_first_frame(); + let [a, _, c] = frames; + for id in frames { + assert!(!auto_leadings(&app, id).is_empty(), "frame {id} holds text"); + } + app.run("text.setFormat", json!({"ids": [c], "autoLeading": 150})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("ui.justification", json!({})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("autoLeading"), "the hidden frame differs"); + set(&mut app, "autoLeading", json!(200)); + set(&mut app, "preview", json!(true)); + frame(&mut app); + for id in frames { + assert!(auto_leadings(&app, id).iter().all(|l| *l == 200.0), "the preview shows on frame {id}"); + } + super::super::cancel(&mut app); + assert!(auto_leadings(&app, a).iter().all(|l| *l == 120.0)); + assert!(auto_leadings(&app, c).iter().all(|l| *l == 150.0), "Cancel restores the hidden frame"); + + app.run("ui.justification", json!({})).unwrap(); + // A selection made while the dialog is open does not change what it acts on. + app.run("select.set", json!({"ids": [other]})).unwrap(); + set(&mut app, "autoLeading", json!(140)); + let undo = app.session.active().unwrap().history.undo.len(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + for id in frames { + assert!(auto_leadings(&app, id).iter().all(|l| *l == 140.0), "frame {id} takes the change"); + } + assert!(auto_leadings(&app, other).iter().all(|l| *l == 120.0), "other type keeps its paragraphs"); + app.run("edit.undo", json!({})).unwrap(); + assert!(auto_leadings(&app, c).iter().all(|l| *l == 150.0), "undo restores the hidden frame"); + } + /// When paragraphs in scope disagree, only shared justification values are filled in. #[test] fn justification_dialog_mixed_paragraph_fields_show_blank() { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index cd311d45a8..f56755e184 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -261,7 +261,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "type.convertListToText", "Convert to Text", "", - "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", + "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs, in every frame of a threaded story (or of those the Type tool's selection touches), become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", ), ( "align.horizontalLeft", diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 64daf1ac76..5718aea0ab 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -1,6 +1,6 @@ //! OpenType panel: per-character OpenType features (ligatures, alternates, swashes, small caps, -//! ordinals, fractions, figure style, position) for the selected text or the Type tool's selected -//! characters. +//! ordinals, fractions, figure style, position) for the selected text (every frame of each +//! selected threaded story) or the Type tool's selected characters. use egui::Ui; use serde_json::json; @@ -112,6 +112,22 @@ fn position_label(shown: Option) -> &'static str { shown.and_then(|i| POSITIONS.get(i).copied()).unwrap_or("Mixed") } +/// The Figure menu entry for the characters the panel acts on, or `None` when they differ. +fn shown_figure(app: &VectorcraftApp) -> Option { + shared(app, |s| figure(&OtFeatures::from_tags(s.features.iter().map(String::as_str)))) +} + +/// Whether `flag` is on for every character the panel acts on (`Some(true)`), for none of them +/// (`Some(false)`), or only for some (`None`). +fn shown_flag(app: &VectorcraftApp, flag: Flag) -> Option { + shared(app, |s| flag.get(&OtFeatures::from_tags(s.features.iter().map(String::as_str)))) +} + +/// The Figure menu's text for entry `shown`: Mixed when the characters differ. +fn figure_label(shown: Option) -> &'static str { + shown.and_then(|i| FIGURES.get(i).copied()).unwrap_or("Mixed") +} + /// A change one of the panel's controls makes to the features of each character it acts on. #[derive(Clone, Copy)] enum Change { @@ -161,7 +177,8 @@ fn set(app: &mut VectorcraftApp, f: OtFeatures) { } /// Send `tags` as the `key` param (`features` or `featureChanges`) for the Type tool's range or -/// caret, or else for the selected type. +/// caret, or else for every frame of the selected stories +/// ([`super::character::selected_stories`]). fn run_features(app: &mut VectorcraftApp, key: &str, tags: Vec) { // After Select All in threaded text: every frame of the story. let story = super::character::text_story(app).and_then(|story| story.first().copied()); @@ -169,7 +186,11 @@ fn run_features(app: &mut VectorcraftApp, key: &str, tags: Vec) { (Some(_), Some(first)) => ("text.setRangeStyle", json!({ "id": first.0, "story": true })), (Some((id, a, b)), None) if b > a => ("text.setRangeStyle", json!({ "id": id.0, "start": a, "end": b })), (Some((id, _, _)), None) => ("text.setRangeStyle", json!({ "id": id.0 })), - (None, _) => ("text.setStyle", json!({})), + (None, _) => { + let mut p = json!({}); + super::character::target_selected_stories(app, &mut p); + ("text.setStyle", p) + } }; p[key] = json!(tags); if let Err(e) = app.run(cmd, p) { @@ -184,10 +205,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; let cur = OtFeatures::from_tags(st.features.iter().map(String::as_str)); let mut change = None; + let fig = shown_figure(app); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Figure:")); - let fig = figure(&cur); - if let Some(i) = widgets::dropdown(ui, "ot-figure", FIGURES[fig], &FIGURES, 170.0) { + if let Some(i) = widgets::dropdown(ui, "ot-figure", figure_label(fig), &FIGURES, 170.0) { change = Some(Change::Figure(i)); } }); @@ -199,26 +220,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); widgets::divider(ui); - let mut next = cur; - if let Some(c) = change { - c.apply(&mut next); - } + // A row whose characters differ shows a dash, and a click turns the feature on for all of them. for (label, flag) in rows() { - let on = flag.get(&next); - if widgets::check(ui, label, on, true) { - change = Some(Change::Toggle(flag, !on)); + let shown = shown_flag(app, flag); + if widgets::check3(ui, label, shown, true) { + change = Some(Change::Toggle(flag, shown != Some(true))); } } // The font, not the text, decides which numbered sets can be offered. Each set is an // independent feature, so the menu permits several at once. A font's own label wins over - // the numbered fallback. See the public Stylistic Sets help page. - let sets = offered_sets(&st); + // the numbered fallback. See the public Stylistic Sets help page. A set is checked when every + // character has it. + let sets: Vec<(StylisticSet, Flag, bool)> = offered_sets(&st) + .into_iter() + .map(|set| { + let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); + let on = shown_flag(app, flag) == Some(true); + (set, flag, on) + }) + .collect(); ui.add_enabled_ui(!sets.is_empty(), |ui| { ui.menu_button(tl!("Stylistic Sets"), |ui| { widgets::menu_scroll(ui, |ui| { - for set in &sets { - let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); - let on = flag.get(&next); + for (set, flag, on) in &sets { + let (flag, on) = (*flag, *on); if widgets::check_name(ui, &set_label(set), on) { change = Some(Change::Toggle(flag, !on)); } @@ -481,6 +506,90 @@ mod tests { } } + /// The features of every run of `id`, each as its tags. + fn frame_features(app: &VectorcraftApp, id: u64) -> Vec> { + match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.features.clone()).collect(), + _ => panic!("not text"), + } + } + + /// With the Selection tool a selected frame of threaded text stands for its whole story: the + /// Figure and Position menus show Mixed when a locked or hidden frame differs, and a choice + /// reaches every frame in one undo step, each keeping its other features, while other type keeps + /// its own. A story selected through two frames and a group is changed once. + #[test] + fn selection_tool_features_reach_every_frame_of_the_story() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog 1234.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other 12", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("text.setStyle", json!({"ids": [c], "features": ["onum", "smcp", "sups", "-liga"]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + for id in [a, b, c] { + assert!(!frame_features(&app, id).is_empty(), "frame {id} holds text"); + } + assert_eq!(shown_figure(&app), None, "the hidden frame's figures differ"); + assert_eq!(figure_label(shown_figure(&app)), "Mixed"); + assert_eq!(shown_position(&app), None, "the hidden frame's position differs"); + let hidden = frame_features(&app, c); + let has = |app: &VectorcraftApp, id: u64, tag: &str| frame_features(app, id).iter().all(|f| f.iter().any(|t| t == tag)); + + let undo = undo_steps(&app); + edit(&mut app, Change::Figure(1)); + assert_eq!(undo_steps(&app), undo + 1, "one undo step"); + assert_eq!(shown_figure(&app), Some(1)); + for id in [a, b] { + assert!(frame_features(&app, id).iter().all(|f| *f == ["tnum"]), "frame {id}: {:?}", frame_features(&app, id)); + } + assert!( + ["smcp", "sups", "-liga", "tnum"].iter().all(|t| has(&app, c, t)) + && frame_features(&app, c).iter().all(|f| !f.iter().any(|t| t == "onum")), + "{:?}", + frame_features(&app, c) + ); + assert!(frame_features(&app, other).iter().all(Vec::is_empty), "other type keeps its features"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(frame_features(&app, c), hidden, "undo restores the hidden frame"); + + // Standard Ligatures is off in the hidden frame only: its row shows a dash, and a click on + // it turns ligatures on in every frame. + let liga = rows().into_iter().find(|(l, _)| *l == "Standard Ligatures").map(|(_, f)| f); + let Some(liga) = liga else { panic!("no Standard Ligatures row") }; + assert_eq!(shown_flag(&app, liga), None); + let ctx = egui::Context::default(); + let draw = |app: &mut VectorcraftApp, ui: &mut Ui| show(app, ui); + super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], draw); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], draw); + let at = super::super::tests_appearance::text_rect(&texts, "Standard Ligatures").center(); + let undo = undo_steps(&app); + super::super::tests_appearance::click(&ctx, &mut app, at, draw); + assert_eq!(undo_steps(&app), undo + 1, "one undo step"); + assert_eq!(shown_flag(&app, liga), Some(true)); + for id in [a, b, c] { + assert!(frame_features(&app, id).iter().all(|f| !f.iter().any(|t| t == "-liga")), "frame {id} has ligatures"); + } + assert!(has(&app, c, "smcp"), "the hidden frame keeps its other features"); + + // Two frames of the story and the other type in a group: each story once. + app.run("object.unlockAll", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a, other]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group, b]})).unwrap(); + edit(&mut app, Change::Position(1)); + for id in [a, b, c, other] { + assert!(frame_features(&app, id).iter().all(|f| f.iter().filter(|t| *t == "sups").count() == 1), "frame {id} has sups once"); + } + } + /// Type with two positions turned on shows the Position menu as mixed, and the entry of one /// of them turns the other off. #[test] diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index e02d81e14c..e594052f60 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -79,13 +79,7 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { /// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result. With /// the Selection tool these are the paragraphs of every frame of the selected stories /// ([`super::character::selected_stories`]). -pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Result { - run_on(app, cmd, p, true) -} - -/// [`run_on_paragraphs`], with the Selection tool on the selected type objects alone unless -/// `stories`. -fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> Result { +pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result { if let Some((id, a, b)) = super::character::text_editing(app) { super::character::end_typing(app); match super::character::text_story(app) { @@ -97,7 +91,7 @@ fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> R p["end"] = json!(b); } } - } else if stories { + } else { super::character::target_selected_stories(app, &mut p); } app.run(cmd, p) @@ -260,7 +254,7 @@ pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { +pub(crate) fn shared_para(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -474,12 +468,13 @@ pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) - } /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the -/// list paragraphs of the selected type, or of those the Type tool's selection touches, become -/// characters of the text and the lists are turned off (`text.convertListToText`, which also moves -/// the Type tool's anchor and caret past the characters inserted at or before them). +/// list paragraphs the Paragraph panel acts on ([`run_on_paragraphs`]: with the Selection tool, +/// every frame of the selected stories) become characters of the text and the lists are turned +/// off (`text.convertListToText`, which also moves the Type tool's anchor and caret past the +/// characters inserted at or before them). pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result { text_style(app).ok_or("select some type first")?; - run_on(app, "text.convertListToText", json!({}), false) + run_on_paragraphs(app, "text.convertListToText", json!({})) } /// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 180f0b1fa1..f6c9f064dc 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -678,6 +678,50 @@ fn convert_to_text_keeps_the_caret_by_its_character_in_threaded_type() { assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(a))); } +/// With the Selection tool, Convert to Text takes the same story as Apply Numbers: the numbers +/// of every frame of the selected story, its locked and hidden frames included, become characters +/// of the text in one undo step, and other type keeps its list. +#[test] +fn selection_tool_convert_to_text_takes_every_frame_of_the_story() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [other], "list": "bullet"})).unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let frame = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.plain_text(), (0..t.paragraph_count()).map(|i| t.para_at(i).list.is_some()).collect::>()), + _ => panic!("not text"), + }; + let undo_steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + app.run("type.applyNumbers", json!({})).unwrap(); + for id in [a, b, c] { + let (_, lists) = frame(&app, id); + assert!(!lists.is_empty() && lists.iter().all(|l| *l), "Apply Numbers numbers frame {id}"); + } + let before = frame(&app, c); + + let steps = undo_steps(&app); + app.run("type.convertListToText", json!({})).unwrap(); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + for id in [a, b, c] { + let (plain, lists) = frame(&app, id); + assert!(lists.iter().all(|l| !*l), "frame {id} leaves the list"); + assert!(plain.contains(".\tThe quick"), "frame {id} holds its numbers as text: {plain:?}"); + } + assert_eq!(frame(&app, other), ("Other".to_string(), vec![true]), "other type keeps its list"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(frame(&app, c), before, "undo restores the hidden frame"); +} + /// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation and /// Justification options back to their defaults in every paragraph it acts on, so the paragraphs /// save neither. diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 880de94c16..4f8ba904de 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -1,10 +1,11 @@ //! Character Styles and Paragraph Styles panels (one implementation, two kinds). //! -//! Click a style to apply it to the selected text (or the Type tool's selected characters); -//! Alt-click applies it and clears overrides. Double-clicking a character style's row opens its -//! Character Style Options (`ui.charStyleOptions`), as the panel menu does; a paragraph style's row -//! opens its Paragraph Style Options (`ui.paraStyleOptions`). The style of the -//! selection is highlighted, with a "+" when the text overrides some of its attributes. New makes +//! Click a style to apply it to the selected text (every frame of each selected threaded story) or +//! the Type tool's selected characters; Alt-click applies it and clears overrides. Double-clicking a +//! character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel +//! menu does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`). +//! The style all of that text has is highlighted, with a "+" when some of the text overrides some +//! of its attributes; text in several styles highlights none. New makes //! a style from the selected text; Alt-click on New first asks for its name (`ui.newCharStyle`, //! `ui.newParaStyle`). @@ -15,8 +16,9 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; use vectorcraft_engine::cmd::{fileio, textstyles}; -use super::character::{end_typing, text_editing, text_style}; +use super::character::{end_typing, shared, text_editing, text_style}; use super::memo::Memo; +use super::paragraph::shared_para; use super::{alt_held, pstate, selection_len, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; @@ -172,14 +174,25 @@ thread_local! { static LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// The selection's style name (Normal when none) and every one of its attributes, written as a -/// style holds them, if text is selected. -fn current(app: &VectorcraftApp, kind: Kind) -> Option<(Option, Map)> { - let (c, p) = text_style(app)?; - Some(match kind { - Kind::Char => (c.style_name.clone(), textstyles::char_attrs(&c)), - Kind::Para => (p.style_name.clone(), textstyles::para_attrs(&p)), - }) +/// The style name (`None`: the Normal style) that every character the panel acts on has (paragraph +/// styles: every paragraph), with the Selection tool in every frame of the selected stories; +/// `None` when they differ or no type is selected. +fn current(app: &VectorcraftApp, kind: Kind) -> Option> { + match kind { + Kind::Char => shared(app, |s| s.style_name.clone()), + Kind::Para => shared_para(app, |p| p.style_name.clone()), + } +} + +/// Whether any character (paragraph styles: paragraph) the panel acts on differs from the style +/// attributes `attrs` in one of them. +fn has_overrides(app: &VectorcraftApp, kind: Kind, attrs: &Map) -> bool { + let differs = |m: Map| attrs.iter().any(|(k, v)| m.get(k) != Some(v)); + let same = match kind { + Kind::Char => shared(app, |s| differs(textstyles::char_attrs(s))), + Kind::Para => shared_para(app, |p| differs(textstyles::para_attrs(p))), + }; + same != Some(false) } /// Parameters targeting the selection: the Type tool's selected characters (paragraph styles: the @@ -196,14 +209,19 @@ fn target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value { } /// [`target`] for applying a style: after Select All in threaded text, all the text of every -/// frame of the story. +/// frame of the story; with the Selection tool, every frame of the selected stories +/// ([`super::character::selected_stories`]). fn apply_target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value { + if text_editing(app).is_none() { + super::character::target_selected_stories(app, &mut p); + return p; + } match super::character::text_story(app) { - Some(story) if text_editing(app).is_some() => { + Some(story) => { p["ids"] = json!(story.iter().map(|f| f.0).collect::>()); p } - _ => target(app, kind, p), + None => target(app, kind, p), } } @@ -242,9 +260,8 @@ fn open_style_options(app: &mut VectorcraftApp, kind: Kind, name: &str) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let t = Tokens::get(ui.ctx()); let all = styles(app, kind); - let cur = current(app, kind); let normal = all.first().map(|s| s.0.clone()).unwrap_or_default(); - let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone())); + let cur_name = current(app, kind).map(|n| n.unwrap_or_else(|| normal.clone())); let mut clicked: Option<(String, egui::Modifiers)> = None; // A double-clicked style's row, whose options open after the list is laid out. let mut options: Option = None; @@ -270,8 +287,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { widgets::list_box(ui, |ui| { for (name, attrs, _) in &all { let is_cur = cur_name.as_deref() == Some(name.as_str()); - // Overrides: an attribute the style sets that the selection doesn't match. - let overridden = is_cur && cur.as_ref().is_some_and(|(_, m)| attrs.iter().any(|(k, v)| m.get(k) != Some(v))); + // Overrides: an attribute the style sets that some of the text doesn't match. + let overridden = is_cur && has_overrides(app, kind, attrs); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); if resp.drag_started_by(egui::PointerButton::Primary) { egui::DragAndDrop::set_payload(ui.ctx(), RowDrag { kind, name: name.clone() }); @@ -310,7 +327,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if scroll_to.is_some() { set_pstate::>(ui.ctx(), kind.scroll_key(), None); } - let has_text = cur.is_some(); + let has_text = text_style(app).is_some(); if let Some((name, m)) = clicked { sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m); // Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. The second @@ -381,7 +398,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let normal = if kind == Kind::Char { "[Normal Character Style]" } else { "[Normal Paragraph Style]" }; let user = sel.as_ref().is_some_and(|s| s != normal); let doomed: Vec<&String> = many.iter().filter(|s| *s != normal).collect(); - let has_text = current(app, kind).is_some() || selection_len(app) > 0; + let has_text = text_style(app).is_some() || selection_len(app) > 0; let p = kind.prefix(); if menu_item(ui, &crate::i18n::fmt(tl!("New {kind} Style…"), &[("kind", kind.what_label())]), true, false) { // A later typing preview must not restore the document from before this capture. diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index a83103aec8..e550f82ab4 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -29,8 +29,8 @@ fn panels_draw_and_apply_headless() { out.textures_delta.clear(); } app.run("charStyle.apply", target(&app, Kind::Char, json!({"name": "Big"}))).unwrap(); - let (name, attrs) = current(&app, Kind::Char).unwrap(); - assert_eq!((name.as_deref(), attrs["size"].as_f64()), (Some("Big"), Some(30.0))); + let (style, _) = text_style(&app).unwrap(); + assert_eq!((current(&app, Kind::Char), style.size), (Some(Some("Big".to_string())), 30.0)); } #[test] @@ -818,6 +818,74 @@ fn the_styles_are_listed_once_per_document_version() { } } +/// The style names of `id`'s runs (`Kind::Para`: of its paragraphs). +fn frame_style_names(app: &VectorcraftApp, kind: Kind, id: u64) -> Vec> { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) if kind == Kind::Char => t.runs.iter().map(|r| r.style.style_name.clone()).collect(), + NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).style_name.clone()).collect(), + _ => panic!("not text"), + } +} + +/// With the Selection tool a selected frame of threaded text stands for its whole story, in both +/// panels: no row shows as the current style while a locked or hidden frame has another style, a +/// row click applies the style to every frame in one undo step and leaves other type alone, the +/// row shows "+" while a hidden frame overrides the style, and an Alt-click clears that override. +#[test] +fn selection_tool_styles_reach_every_frame_of_the_story() { + use egui::Modifiers; + for kind in [Kind::Char, Kind::Para] { + let what = kind.what(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Plain"})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Second", "attrs": {}})).unwrap(); + app.run(&format!("{}.apply", kind.prefix()), json!({"name": "Second", "ids": [c]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + for id in [a, b, c] { + assert!(!frame_style_names(&app, kind, id).is_empty(), "frame {id} holds text"); + } + assert_eq!(current(&app, kind), None, "{what} Styles: the hidden frame has another style"); + + let ctx = egui::Context::default(); + let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + ui.set_width(280.0); + show(app, ui, kind); + }; + let undo = app.session.doc().unwrap().history.undo.len(); + click_text_held(&ctx, &mut app, "Plain", Modifiers::NONE, rows); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "{what} Styles: one undo step"); + for id in [a, b, c] { + assert!(frame_style_names(&app, kind, id).iter().all(|n| n.as_deref() == Some("Plain")), "{what} Styles: frame {id}"); + } + assert!(frame_style_names(&app, kind, other).iter().all(Option::is_none), "{what} Styles: other type keeps its style"); + assert_eq!(current(&app, kind), Some(Some("Plain".to_string()))); + + app.run("text.setFormat", json!({"ids": [c], "kerning": 40, "composer": "singleLine"})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain+"), "{what} Styles: the hidden frame's override shows: {shown}"); + // A new context, so that this click does not count as the second click of a double-click. + click_text_held(&egui::Context::default(), &mut app, "Plain+", Modifiers::ALT, rows); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain"), "{what} Styles: Alt-click clears the override: {shown}"); + app.run("edit.undo", json!({})).unwrap(); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain+"), "{what} Styles: undo brings the override back: {shown}"); + } +} + /// A row dragged from the highlighted styles carries all of them: dropped on Create New Style it /// duplicates each in one undo step and highlights the copies, and dropped on Delete it deletes each /// but the Normal style in one undo step and clears the highlight. A row from outside the highlight @@ -1201,7 +1269,7 @@ fn alt_clicking_new_paragraph_style_asks_for_its_name() { assert!(app.session.doc().unwrap().doc.para_styles.is_empty(), "no style is added before OK"); set_name(&mut app, "Body Copy"); crate::dialogs::confirm(&mut app).unwrap(); - let (_, attrs) = current(&app, Kind::Para).unwrap(); + let attrs = textstyles::para_attrs(&text_style(&app).unwrap().1); let doc = &app.session.doc().unwrap().doc; assert_eq!(doc.para_styles.iter().map(|s| (s.name.as_str(), &s.attrs)).collect::>(), [("Body Copy", &attrs)]); diff --git a/docs/mcp.md b/docs/mcp.md index 84c75a81d0..c1a2a94a89 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8946,7 +8946,9 @@ send an Alignment or Level change for paragraphs that stay in no list. With no t with an unknown `tab`, `ui.bulletsAndNumbering` is an error. Type › Bullets and Numbering › Convert to Text runs `type.convertListToText` on the selected type's paragraphs, or on -those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. The marker of +those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. With the +Selection tool, a selected frame of threaded text names every frame of its story in `ids`, locked and hidden frames +included, as Apply Bullets and Apply Numbers do. The marker of each list paragraph becomes characters at the start of the paragraph: the bullet, or the number and its period as the paragraph showed it, then a tab. They are set in the marker's font, size and color: the style of the paragraph's first character without the attributes the marker leaves out. The paragraph leaves its list. A paragraph at a list level diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b2a44966b..8296665950 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -997,6 +997,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`). - With the Selection tool, a Character or Paragraph panel change to a selected frame of threaded text changes every frame of its story, locked and hidden frames included, as one undo step. The anti-aliasing method, Variable Font axes, the Tabs panel's tab stops and both panels' Reset Panel change the whole story too, and the panels show a value only when every frame of the story has it. A story selected through several of its frames or through a group is changed once, and other type keeps its formatting (VectorCraft's own behavior; `panels::character::tests::selection_tool_changes_reach_every_frame_of_the_story`, `selection_tool_fields_read_every_frame_of_the_story`, `selected_stories_list_each_frame_once`, `panels::paragraph::tests::selection_tool_paragraph_changes_reach_every_frame_of_the_story`, `panels::tabs::tests::selection_tool_tab_stops_reach_every_frame_of_the_story`). + - With the Selection tool, the Justification dialog, the OpenType panel, a Character or Paragraph Styles row and Type › Bullets and Numbering › Convert to Text also change every frame of a selected story, locked and hidden frames included, as one undo step. The Justification dialog acts on the story's frames as they were when it opened. When the frames differ, the OpenType panel's Figure menu shows Mixed, its checkboxes show a dash (a click turns the feature on everywhere), and the style panels highlight no row; a row shows "+" when any frame overrides its style (VectorCraft's own behavior; `dialogs::justification::tests::selection_tool_justification_reaches_every_frame_of_the_story`, `panels::opentype::tests::selection_tool_features_reach_every_frame_of_the_story`, `panels::text_styles::tests::selection_tool_styles_reach_every_frame_of_the_story`, `panels::paragraph::tests::selection_tool_convert_to_text_takes_every_frame_of_the_story`). - Switching from a Type tool to the Eyedropper keeps a whole story selected by Select All: a pick applies to the text of every frame as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. From 5f196fdfffbc5de0924ae948978ae0b81198c824 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:47:46 -0800 Subject: [PATCH 11/89] Type: right-to-left tabbed lines are measured with their line-end trim where it is drawn A right-to-left line draws its last character before its tabs. When that character loses its Japanese-Latin space at the line's end, the layout drew it narrower before placing the tabs, but measured the line with the full advance and took the trim off afterwards. The measured line was narrower than the drawn one by the trim: a right-aligned line ended past the frame's right edge (305 pt in a 300 pt frame), and a line that did not fit as drawn was kept whole. drawn_width now takes the end trim off the glyph it belongs to, so the tabs drawn after it take their stops from where it ends, both where lines break and where a line is measured for alignment. --- crates/text/src/layout/breaking.rs | 13 +++++++++---- crates/text/src/layout/flow.rs | 11 +++++++---- crates/text/src/tests/justify.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 671ebee7a1..9bc79e1bc8 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -243,11 +243,16 @@ pub(super) fn drawn_after_tab<'g>(sg: &'g [SGlyph], drawn: &'g [usize], k: usize /// them) set on a line whose content starts `x_start` from the tab origin: tabs of the text take /// what [`tab_advance`] gives them from where the glyphs drawn before them end, aligning the glyphs /// drawn after them, not the width of their glyph. The tab of a list marker (length 0) keeps its -/// own width. -pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { +/// own width. With `end_trim` (a glyph index and an amount), that glyph is set that much narrower +/// where it is drawn (a line's end trim), before the tabs drawn after it take their stops. +pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64, end_trim: Option<(usize, f64)>) -> f64 { let mut x = x_start; - for (k, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, g))) { - x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0 } else { g.adv }; + for (k, j, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, j, g))) { + x += if g.ch == '\t' && g.len > 0 { + tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0 + } else { + g.adv - end_trim.filter(|&(at, _)| at == j).map_or(0.0, |(_, trim)| trim) + }; } x - x_start } diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 1ee9a3fe50..f722caf22a 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -449,8 +449,9 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC .filter(|&j| j < trimmed) .collect(); let hyphen = if hyphenated { end.checked_sub(1).and_then(|k| glyphs.get(k)).map_or(0.0, |g| hyphen_glyph(g).adv) } else { 0.0 }; - let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0); - drawn_width(glyphs, &drawn, ¶.tabs, ind_l) + hyphen - credit <= width + EPS + // The last glyph's end trim comes off where it is drawn, before the tabs after it. + let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k).map(|&trim| (k, trim))); + drawn_width(glyphs, &drawn, ¶.tabs, ind_l, credit) + hyphen <= width + EPS }; let fits: Option<&dyn Fn(usize, bool) -> bool> = (!cx.vertical && bidi.is_some_and(|b| b.has_rtl())).then_some(&drawn_fits); let line = |ty| { @@ -649,9 +650,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]); // The line is as wide as it is drawn: its glyphs before its trailing spaces in drawing order, - // each tab up to its stop from where the glyphs drawn before it end. + // each tab up to its stop from where the glyphs drawn before it end, with the last glyph's end + // trim taken off where it is drawn. let measured: Vec = order.iter().map(|&offset| index + offset).filter(|&j| j < trimmed).collect(); - let measured_width = drawn_width(sg, &measured, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; + let trim_at = trimmed.checked_sub(1).filter(|&k| k >= index).map(|k| (k, end_trim)); + let measured_width = drawn_width(sg, &measured, ¶.tabs, position.ind_l, trim_at) + hyphen.as_ref().map_or(0.0, |g| g.adv); // The glyphs before `stop` in visual order, each with the advance it is set with. A list // marker does not hang: it is counted with no width. let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { diff --git a/crates/text/src/tests/justify.rs b/crates/text/src/tests/justify.rs index bc1e5b1b13..5c82700cde 100644 --- a/crates/text/src/tests/justify.rs +++ b/crates/text/src/tests/justify.rs @@ -668,6 +668,36 @@ fn a_right_to_left_line_with_a_tab_and_many_breaks_wraps_when_it_does_not_fit_as } } +/// A right-to-left line with a tab whose last character loses its Japanese–Latin space at the +/// line's end is measured with that space taken off where the character is drawn, before the tab: +/// right aligned, it ends on the frame's right edge, and it wraps when it does not fit as drawn. +#[test] +fn a_right_to_left_tabbed_line_is_measured_without_its_line_end_space() { + use vectorcraft_doc::Mojikumi; + // א (0), ב (2), tab (4), ג (5), 漢 (7), then a Hebrew word too long for the frame: the first + // line ends after 漢, which loses the quarter em it had before the Hebrew word. + let text = format!("אב\tג漢{}", "ד".repeat(40)); + let lay = |width: f64| { + let mut t = area(&text, width, Justify::Right, no_ranges()); + t.para.mojikumi = Mojikumi::GyomatsuYakumonoZenkaku; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + layout(db(), &t) + }; + let l = lay(300.0); + assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len()); + assert!(l.glyphs.iter().filter(|g| g.line == 0).all(|g| g.byte < 10), "the first line ends after 漢"); + let (_, right) = drawn_extent(&l, &text, 0); + assert!((right - 300.0).abs() < 1e-6, "ends on the right edge: {right}"); + assert!((l.lines[0].x1 - right).abs() < 1e-6, "the line's record ends where it is drawn: {}", l.lines[0].x1); + // Drawn, that line is as wide as the stop and the text after the tab (אב). In a frame a point + // narrower, less than the quarter em, it does not fit and the first line stays in the frame. + let after_tab: f64 = l.glyphs.iter().filter(|g| g.byte < 4).map(|g| g.advance).sum(); + let width = 150.0 + after_tab - 1.0; + let l = lay(width); + let (left, right) = drawn_extent(&l, &text, 0); + assert!(left >= -1e-6 && right <= width + 1e-6, "the first line stays in the frame: {left} {right} (frame {width})"); +} + /// Point type has no measure to fill: a justified line with a tab keeps its natural width. #[test] fn justified_point_type_with_a_tab_keeps_its_width() { From 6b54622e721b015b5f355431ffd37c0dbce98523 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:50:50 -0800 Subject: [PATCH 12/89] Type: a tabbed line that steps back ends at the latest break that fits as drawn A glyph with a negative advance can draw a later break narrower than an earlier one, so bisecting the breaks past the 256 measured one by one could end a tabbed right-to-left line at an earlier break than the latest that fits. Such a line now measures up to 1,024 breaks, or hyphenation points, one by one before bisecting the rest. --- crates/text/src/layout/breaking.rs | 37 +++++++++++------- crates/text/src/layout/wrapping_tests.rs | 49 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 671ebee7a1..9e4cb10e36 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -256,6 +256,11 @@ pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_do /// back before bisecting the earlier ones. const MAX_DRAWN_CHECKS: usize = 256; +/// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one when a glyph of +/// the line has a negative advance: a later break can then be drawn narrower than an earlier one, +/// and bisecting can miss the latest break that fits. +const MAX_DRAWN_CHECKS_STEPPING_BACK: usize = 1024; + /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make /// millions of outlines. const MAX_LEADER_COPIES: f64 = 4096.0; @@ -357,8 +362,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// from `i` ending before glyph `end` (with a hyphen when `hyphenated`) fits `width` as it is drawn: /// once the line holds a tab, whose width depends on what is drawn before it, the line reads on /// while the least it can be drawn at fits `width`, then ends at the latest legal break where it -/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, the earlier ones without a -/// hyphen are bisected); hyphenation points are measured the same way. +/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, or +/// [`MAX_DRAWN_CHECKS_STEPPING_BACK`] when a glyph of the line has a negative advance, the earlier +/// ones without a hyphen are bisected); hyphenation points are measured the same way. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -377,24 +383,26 @@ pub(super) fn break_line( fits: Option<&dyn Fn(usize, bool) -> bool>, ) -> (usize, bool) { // The line ending at `chosen`, once it holds a tab, when it fits as drawn; otherwise the latest - // of the legal `breaks` before it that fits as drawn, measuring at most MAX_DRAWN_CHECKS of - // them from the latest back and bisecting the earlier ones without a hyphen; the first of them - // when none of those measured fits. - let settle = |tabbed: bool, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { + // of the legal `breaks` before it that fits as drawn, measuring at most `checks` of them from + // the latest back and bisecting the earlier ones without a hyphen; the first of them when none + // of those measured fits. + let settle = |tabbed: bool, checks: usize, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { let Some(fits) = fits.filter(|_| tabbed) else { return chosen }; if fits(chosen.0, chosen.1) { return chosen; } let earlier = breaks.get(breaks.partition_point(|b| b.0 <= i)..breaks.partition_point(|b| b.0 < chosen.0)).unwrap_or_default(); - if let Some(&b) = earlier.iter().rev().take(MAX_DRAWN_CHECKS).find(|b| fits(b.0, b.1)) { + if let Some(&b) = earlier.iter().rev().take(checks).find(|b| fits(b.0, b.1)) { return b; } // Without a hyphen, a line that ends later is drawn no narrower, so those breaks bisect. - let rest = earlier.get(..earlier.len().saturating_sub(MAX_DRAWN_CHECKS)).unwrap_or_default(); + let rest = earlier.get(..earlier.len().saturating_sub(checks)).unwrap_or_default(); let plain: Vec<&(usize, bool)> = rest.iter().filter(|b| !b.1).collect(); let fitting = plain.partition_point(|b| fits(b.0, b.1)); fitting.checked_sub(1).and_then(|k| plain.get(k).copied()).or(earlier.first()).copied().unwrap_or(chosen) }; + // How many breaks a line measures as drawn one by one. + let checks = |back: f64| if back > 0.0 { MAX_DRAWN_CHECKS_STEPPING_BACK } else { MAX_DRAWN_CHECKS }; let mut breaks: Vec<(usize, bool)> = Vec::new(); let mut tabbed = false; if !width.is_finite() { @@ -462,7 +470,7 @@ pub(super) fn break_line( let cluster_ink = if is_tab { 0.0 } else { cluster_width }; let cluster_back: f64 = if is_tab { 0.0 } else { g[j..cluster_end].iter().map(|glyph| (-glyph.adv).max(0.0)).sum() }; if gl.ch == '\u{2028}' { - return settle(tabbed, (cluster_end, false), &breaks); + return settle(tabbed, checks(back), (cluster_end, false), &breaks); } // Measured as the line is set when it ends with this cluster: only the cluster's last glyph // is narrowed, and only where the line may end. @@ -509,7 +517,7 @@ pub(super) fn break_line( j = cluster_end; } if j >= g.len() { - return settle(tabbed, (g.len(), false), &breaks); + return settle(tabbed, checks(back), (g.len(), false), &breaks); } // Hyphenate the word that overflows. A list marker starting the line is not part of a word. let word_start = last_break.map_or(first, |b| b.0); @@ -534,18 +542,19 @@ pub(super) fn break_line( .filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) .collect(); // The points the line may end at, the latest first. Measured as drawn, at most - // MAX_DRAWN_CHECKS of them are measured one by one, and the earlier ones are bisected: + // `checks(back)` of them are measured one by one, and the earlier ones are bisected: // each ends with a hyphen, so a later one is drawn no narrower. let points: Vec = pts.iter().rev().copied().filter(|&k| k <= j && k > i).collect(); let drawn = fits.filter(|_| tabbed); - for &k in points.iter().take(if drawn.is_some() { MAX_DRAWN_CHECKS } else { usize::MAX }) { + let one_by_one = checks(back); + for &k in points.iter().take(if drawn.is_some() { one_by_one } else { usize::MAX }) { let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; if drawn.map_or(w <= width + EPS, |fits| fits(k, true)) { return (k, true); } } if let Some(fits) = drawn { - let earlier: Vec = points.iter().skip(MAX_DRAWN_CHECKS).rev().copied().collect(); + let earlier: Vec = points.iter().skip(one_by_one).rev().copied().collect(); let fitting = earlier.partition_point(|&k| fits(k, true)); if let Some(&k) = fitting.checked_sub(1).and_then(|n| earlier.get(n)) { return (k, true); @@ -555,7 +564,7 @@ pub(super) fn break_line( // Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only // cluster starts, so the resulting end always keeps each source cluster together. match last_break { - Some(b) if b.0 > i => settle(tabbed, b, &breaks), + Some(b) if b.0 > i => settle(tabbed, checks(back), b, &breaks), _ => (j, false), } } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index eae22bab8a..a8630aad5e 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -646,6 +646,55 @@ fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() { assert_eq!(ended, (5, false)); } +/// A long tabbed line with a glyph that steps back ends at the latest break that fits as drawn, even +/// where a later break than one that does not fit fits again. +#[test] +fn a_long_tabbed_line_that_steps_back_ends_at_the_latest_break_that_fits_as_drawn() { + // a (0), tab (1), then b and a space for each u: breaks before 4 + 2u. + let text = format!("a\t{}", "b ".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -0.5 } else { 1.0 }, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end == 200; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 1e6, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (200, false)); +} + +/// A tabbed line with a glyph that steps back, ending in a word with more hyphenation points than +/// are measured one by one, ends at the latest point that fits as drawn. +#[test] +fn a_tabbed_line_that_steps_back_ends_at_the_latest_hyphenation_point_that_fits_as_drawn() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + // a (0), tab (1), then a word of 400 syllables of two glyphs from glyph 2: points before 4, 6, ... + let text = format!("a\t{}", "कि".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -1.0 } else { 1.0 }, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + for glyph in &mut glyphs { + glyph.hyphen_language = crate::hyphen::Language::from_tag(Some("hi")); + glyph.sx = 0.001; + } + let drawn_fits = |end: usize, _hyphenated: bool| end <= 20 || end == 50; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = break_line( + &text, + &glyphs, + 0, + 600.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + fits, + ); + assert_eq!(ended, (50, true)); +} + /// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed /// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last /// glyph of the last cluster counts, and only where the line may end. From d47cbe5f775d3ad0964d3b6134485c694fccf2d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:06:12 -0800 Subject: [PATCH 13/89] 3D: colored-light faces keep unplaced gradients fitted to the face The color cover of a composed face grew past the underlay's seam, and a source gradient without a placement fitted itself to that larger rectangle. The face now pins such a gradient to its bounds grown by 1 pt, as before the cover grew. docs/three-d.md states the measured limits of the 1:1 comparison. --- crates/effects/src/lighting.rs | 34 +++++++++++++++++++++++++++++++++- docs/three-d.md | 6 ++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 378bfa5621..257576822a 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -326,7 +326,13 @@ fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, use vectorcraft_doc::color::BlendMode; use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask}; use vectorcraft_geom::{Rect, shapes}; - let (base, mask) = opaque_paint(paint); + // An unplaced gradient (and a freeform gradient without points) stays fitted to the face's + // bounds grown by 1 pt, not to the larger cover below. + let mut paint = paint.clone(); + if let Paint::Gradient(g) = &mut paint { + g.pin(path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0)); + } + let (base, mask) = opaque_paint(&paint); // Clip once to the union of this single-contour face and its round seam stroke. let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&path.to_bezpath(), seam)), Appearance::default()); // Opaque rectangles cover the complete seam stroke, without separately antialiasing @@ -469,4 +475,30 @@ mod tests { assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]); assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch"); } + + /// Under colored light, a face's unplaced source gradient is fitted to the face's bounds grown by + /// 1 pt, whatever the size of the cover rectangle that draws it; a placed one keeps its placement. + #[test] + fn composed_faces_fit_unplaced_gradients_to_the_face_not_the_cover() { + use vectorcraft_doc::NodeKind; + use vectorcraft_geom::{Rect, shapes}; + use vectorcraft_three_d::{RgbGradient, ShadeGradient}; + let face = shapes::rectangle(Rect::new(10.0, 20.0, 50.0, 40.0)); + let ramp = |b| ShadeGradient { start: Point::new(10.0, 20.0), end: Point::new(50.0, 20.0), brightness: [b, 1.0] }; + let light = RgbGradient { channels: [ramp(0.2), ramp(0.5), ramp(0.8)] }; + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let unplaced = GradientPaint { gradient: Gradient::new(GradientKind::Linear, stops), geom: None, angle: 0.0, swatch: None, freeform: None }; + let placed = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(80.0, 0.0), aspect: 1.0, focal: None }; + let fitted = unplaced.resolve(Rect::new(9.0, 19.0, 51.0, 41.0)); + for (geom, expected) in [(None, fitted), (Some(placed), placed)] { + let paint = Paint::Gradient(Box::new(GradientPaint { geom, ..unplaced.clone() })); + for seam in [0.05, 0.75] { + let art = facet_art(vectorcraft_doc::NodeId(1), &face, &paint, light, seam); + let NodeKind::Group { children, clip: true } = &art.kind else { panic!("a clipped composite") }; + let base = &children[1].children().unwrap()[0]; + let Paint::Gradient(g) = base.appearance.items[0].paint() else { panic!("the source gradient") }; + assert_eq!(g.geom, Some(expected), "seam {seam}"); + } + } + } } diff --git a/docs/three-d.md b/docs/three-d.md index 4ba4683142..c0c40d4f24 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -123,8 +123,10 @@ How a facet carries its seam depends on the lights and the paint: of themselves whose seams are 2 pt wide, reaching 1 pt past each edge, and one clip to the union of the facets and their seams removes what the underlay paints past the surface. Where two facets' antialiased edges meet, the underlay fills the pixels they leave partly uncovered with the neighbors' own light, so no black channel backdrop - or background shows there. Drawn at 1:1, inside the surface each color channel is within 3 levels of the same art - drawn at 3x and averaged down, and every pixel is opaque (VectorCraft's own measure). + or background shows there. Compared with the same art drawn at 3x and averaged down, away from the surface's outline + and from places where the light changes sharply, a pixel drawn at 1:1 is opaque where the 3x art is opaque, and + otherwise its alpha is within 3 levels. Inflate seen straight on with an opaque fill also has each color channel + within 3 levels (VectorCraft's own measure). - Extrude & Bevel (Classic), Rotate (Classic) and Revolve (Classic): a path and a seam for every face; gradient and pattern faces seam only the base paint, and the shading and highlight layers cover the face alone. The Wireframe surface draws edges only, with no seam. From 90b053f480524d3224f8c51e32827b9a75cee316 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:16:18 -0800 Subject: [PATCH 14/89] i18n: Italian translates the messages no catalog had a row for The untranslated report listed 167 status and error messages with no row in any complete-message catalog, and 13 more that other catalogs translate but Italian did not, so with the interface in Italian these messages showed in English. - it.tsv gains 114 `@msg` rows for the listed messages: image export and encoding errors, color profiles, color books and swatch exchange files, binary DXF and DWG import, 3D and Revolve limits, and script, zip and file errors. - it.tsv gains 12 `@msg` rows for the Banner Hems and Grommets errors, three parameter checks and the failed recovery save. - The rows sit in two commented blocks after the existing message rows. - The 53 listed messages that open #7211, #7378 and #7405 translate (composite fonts, SVG filters, read roots and graph designs), and the justification order error that open #7116 translates, are left out, so no row is added twice. - Placeholders, parameter names, command ids and literal values stay as the English writes them. The translations use the catalog's existing terms. - docs/localization-parity.md and docs/target-app-parity.md give the Italian counts: 3,560 `@msg` and 48 `@msgplural` rows, 54 messages still listed. --- crates/ui-egui/src/i18n/it.tsv | 128 +++++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0ac2816b07..ee47510892 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -8920,6 +8920,134 @@ breakText Glyphs Glifi @msg {what} can't be intertwined {what} non può essere intrecciato @msg {what} doesn't fit the paper with its bleed and marks {what} non entra nella carta con l'abbondanza e i segni @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} verrebbe stampato su più di {MAX_TILES} riquadri: riducilo +# Messages no catalog translated yet: image export and color profiles, color books and swatch exchange files, 3D and Revolve, and script and file errors. +@msg 3D effects require finite coordinates within 100 million points Gli effetti 3D richiedono coordinate finite entro 100 milioni di punti +@msg 3D effects require one to ten lights Gli effetti 3D richiedono da una a dieci luci +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Profondità BMP {_1}: 1, 4, 8, 16, 24 o 32 bit per pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Codifica BMP non riuscita: {width} × {height} pixel non sono compatibili con il formato o con il buffer dei pixel +@msg GIF encoding failed: the palette image is malformed Codifica GIF non riuscita: l'immagine con palette non è valida +@msg GIF encoding failed: {e} Codifica GIF non riuscita: {e} +@msg JPEG colour profile: {e} Profilo colore JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Codifica JPEG non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg JPEG encoding failed: {e} Codifica JPEG non riuscita: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Codifica PNG non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg PNG encoding failed: the palette image is malformed Codifica PNG non riuscita: l'immagine con palette non è valida +@msg PNG encoding failed: {e} Codifica PNG non riuscita: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Codifica PNG non riuscita: {width} × {height} pixel non è una dimensione valida per un'immagine +@msg PSD colour profile: {e} Profilo colore PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Codifica PSD non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg Revolve profile contains invalid coordinates Rivoluzione: il profilo contiene coordinate non valide +@msg Revolve profile is too complex Rivoluzione: il profilo è troppo complesso +@msg Revolve projection is not finite Rivoluzione: la proiezione non è finita +@msg Revolve requires finite profile coordinates within 100 million points Rivoluzione richiede coordinate del profilo finite entro 100 milioni di punti +@msg Revolve surface is too complex; reduce Segments or simplify the profile Rivoluzione: la superficie è troppo complessa; riduci Segmenti o semplifica il profilo +@msg Revolve surface is too complex; simplify the profile Rivoluzione: la superficie è troppo complessa; semplifica il profilo +@msg Revolve visibility cannot split the projected boundary Rivoluzione: il calcolo della visibilità non può dividere il contorno proiettato +@msg Revolve visibility creates too many fragments Rivoluzione: il calcolo della visibilità crea troppi frammenti +@msg Revolve visibility creates too many vector anchors Rivoluzione: il calcolo della visibilità crea troppi punti di ancoraggio vettoriali +@msg Revolve visibility exceeds its geometry work limit Rivoluzione: il calcolo della visibilità supera il suo limite di lavoro geometrico +@msg Revolve visibility exceeds its spatial index limit Rivoluzione: il calcolo della visibilità supera il suo limite di indice spaziale +@msg Revolve visibility has too many boundary crossings Rivoluzione: il calcolo della visibilità ha troppi attraversamenti del contorno +@msg Revolve visibility has too many input anchors Rivoluzione: il calcolo della visibilità ha troppi punti di ancoraggio in ingresso +@msg Revolve visibility requires convex cap sectors Rivoluzione: il calcolo della visibilità richiede settori convessi delle basi +@msg Revolve visibility requires finite projected faces Rivoluzione: il calcolo della visibilità richiede facce proiettate finite +@msg SVG options: {e} Opzioni SVG: {e} +@msg Save for Web settings: {e} Impostazioni di Salva per il web: {e} +@msg TIFF colour profile: {e} Profilo colore TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Codifica TIFF non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg TIFF encoding failed: {e} Codifica TIFF non riuscita: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Profondità Targa {_1}: 16, 24 o 32 bit per pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Codifica Targa non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg The 3D projection is not finite La proiezione 3D non è finita +@msg The 3D shadow is too complex; turn Shadows off or simplify the art L'ombra 3D è troppo complessa; disattiva Ombre o semplifica la grafica +@msg The shape contains invalid coordinates La forma contiene coordinate non valide +@msg Too many Revolve faces for visibility evaluation Troppe facce di Rivoluzione per il calcolo della visibilità +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft legge al massimo {ASE_MAX_ENTRIES} colori e gruppi da un file di scambio campioni, e questa libreria ne ha {entries} +@msg WebP encoding failed: {e} Codifica WebP non riuscita: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` è smallThumb, mediumThumb, largeThumb, smallList o largeList +@msg a line can't run from a point to itself una linea non può andare da un punto a sé stesso +@msg a line needs at least two points una linea richiede almeno due punti +@msg a number cannot be read impossibile leggere un numero +@msg a swatch exchange block is too long un blocco del file di scambio campioni è troppo lungo +@msg a swatch exchange name is too long un nome nel file di scambio campioni è troppo lungo +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels un'immagine di {sw} × {sh} non può essere ridimensionata a {width} × {height} pixel +@msg a {width} × {height} image is too large un'immagine di {width} × {height} è troppo grande +@msg a {width} × {height} image: {e} un'immagine di {width} × {height}: {e} +@msg an image could not be written: {e} impossibile scrivere un'immagine: {e} +@msg an image that couldn't be read was left out un'immagine che non è stato possibile leggere è stata omessa +@msg can't open “{name}”: {e} impossibile aprire “{name}”: {e} +@msg cannot align zip entries to {align} bytes impossibile allineare le voci zip a {align} byte +@msg cannot open the automation {kind} root `{_1}`: {e} impossibile aprire la cartella radice {kind} dell'automazione `{_1}`: {e} +@msg cannot read profile: {0} impossibile leggere il profilo: {0} +@msg color book version {version} isn't supported (only 1) la versione {version} del libro colori non è supportata (solo 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) i libri colori nello spazio colore {other} non sono supportati (solo RGB, CMYK e Lab) +@msg file name too long to zip: {_1} nome file troppo lungo per un archivio zip: {_1} +@msg file name too long to zip: {name} nome file troppo lungo per un archivio zip: {name} +@msg freeform line {i}: {e} linea libera {i}: {e} +@msg freeform point {i}: {e} punto libero {i}: {e} +@msg give `{key}` or `{other}`, not both indica `{key}` o `{other}`, non entrambi +@msg grayscale profiles can't be working spaces yet i profili in scala di grigi non possono ancora essere spazi di lavoro +@msg input contains NaN or infinite coordinates l'input contiene coordinate NaN o infinite +@msg input path could not be closed impossibile chiudere il tracciato di input +@msg invalid ICC profile: {0} profilo ICC non valido: {0} +@msg invalid scale {scale} scala non valida {scale} +@msg its localized names take more than {_1} KB i suoi nomi localizzati occupano più di {_1} KB +@msg its objects are nested too deep ({e}) i suoi oggetti sono annidati troppo in profondità ({e}) +@msg its tag table lists the `{_1}` tag more than once la sua tabella dei tag elenca il tag `{_1}` più di una volta +@msg not a graphic style library: {e} non è una libreria di stili grafici: {e} +@msg not a swatch library (.vcswatches or .gpl) non è una libreria campioni (.vcswatches o .gpl) +@msg not a swatch library (format `{_1}`) non è una libreria campioni (formato `{_1}`) +@msg not a swatch library: {e} non è una libreria campioni: {e} +@msg not an action set file ({e}) non è un file di set di azioni ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray lo spazio colore {other} del profilo non è RGB, CMYK o scala di grigi +@msg regular expression too large espressione regolare troppo grande +@msg script file is not UTF-8 text: {value} il file di script non è testo UTF-8: {value} +@msg stop {i}: {e} cursore colore {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) la versione {major}.{minor} del file di scambio campioni non è supportata (solo 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors il libro colori contiene più di {ASE_MAX_ENTRIES} colori +@msg the color book is cut short il libro colori è troncato +@msg the color book's names take more than {_1} MB i nomi del libro colori occupano più di {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution l'immagine misurerebbe {w} × {h} pixel; le esportazioni raster sono limitate a {MAX_RASTER_SIDE} pixel per lato e {_1} megapixel: riduci la scala o la risoluzione +@msg the optimised image can't be read back: {e} impossibile rileggere l'immagine ottimizzata: {e} +@msg the pixel buffer doesn't match the image size il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg the preview: {e} l'anteprima: {e} +@msg the request may not read the New Document profiles folder la richiesta non può leggere la cartella dei profili di nuovo documento +@msg the shapes are too degenerate to combine le forme sono troppo degeneri per essere combinate +@msg the shapes cross themselves and each other too often to combine le forme si intersecano con sé stesse e tra loro troppe volte per essere combinate +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups il file di scambio campioni contiene più di {ASE_MAX_ENTRIES} colori e gruppi +@msg the swatch exchange file is cut short il file di scambio campioni è troncato +@msg the {_1} plate can't be read: {e} impossibile leggere la lastra {_1}: {e} +@msg this binary DXF file is damaged or cut short questo file DXF binario è danneggiato o troncato +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that questo è un disegno DWG, che non può essere letto: salvalo dall'app CAD come DXF e apri quel file +@msg too many classes troppe classi di caratteri +@msg too many groups troppi gruppi +@msg too many layers for PSD (at most 32767) troppi livelli per PSD (al massimo 32767) +@msg too many swatch exchange blocks troppi blocchi nel file di scambio campioni +@msg unknown colour profile: {0} profilo colore sconosciuto: {0} +@msg {_1} clashes with a built-in profile name {_1} coincide con il nome di un profilo incorporato +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixel è troppo grande per GIF (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {_1}: no usable transform to/from sRGB {_1}: nessuna trasformazione utilizzabile da/verso sRGB +@msg {name} is not a CMYK profile {name} non è un profilo CMYK +@msg {name} is not an RGB profile {name} non è un profilo RGB +@msg {path}: could not be read {path}: lettura non riuscita +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixel è troppo grande per JPEG (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixel è troppo grande per Targa (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixel è troppo grande per PSD (al massimo {MAX_SIDE} pixel per lato): riduci la risoluzione +# Messages other catalogs translate and Italian did not: Banner Hems and Grommets, parameter checks and recovery data. +@msg `{key}` must be from 0.01 to 1000 pt `{key}` deve essere compreso tra 0,01 e 1000 pt +@msg select the banner, or mark every artboard seleziona lo striscione, oppure contrassegna ogni tavola da disegno +@msg the banner has no width or height lo striscione non ha larghezza o altezza +@msg the grommet inset is too large for the banner la distanza degli occhielli dal bordo è troppo grande per lo striscione +@msg the hem is too deep for the banner l'orlo è troppo profondo per lo striscione +@msg the spacing makes more than {MAX_GROMMETS} grommets la spaziatura produce più di {MAX_GROMMETS} occhielli +@msg there is no banner to mark non c'è nessuno striscione da contrassegnare +@msg turn on at least one side attiva almeno un lato +@msg unknown parameter `{within}{key}`; {why} parametro sconosciuto `{within}{key}`; {why} +@msg with no hem and no grommets there is nothing to mark senza orlo e senza occhielli non c'è nulla da contrassegnare +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} deve essere {lo}–{hi}{unit} (ricevuto {v}) +@msg {saving} failed: {e} {saving} non riuscito: {e} Banner Hems and Grommets… Orli e occhielli per striscioni… Banner Hems and Grommets Orli e occhielli per striscioni Hem Orlo diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6cbad8f5e3..b8f7d8b097 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -50,7 +50,7 @@ plus review. Native-speaker review is human work and isn't in the hours. | Language | Code | Translated | Status | |---|---|---|---| | Traditional Chinese (Taiwan) | `zh-hant` | 4,892 rows, 790 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted (80% overall) | partial | -| Italian | `it` | 8,757 rows, 3,422 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it | full | +| Italian | `it` | 8,924 rows, 3,560 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 54 missing messages for it, which open #7116, #7211, #7378 and #7405 translate | full | | Dutch | `nl` | 7,299 rows, 1,609 of them `@msg` and 47 `@msgplural` (counted 2026-11-13 on `main` at `9a1a158a3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for German; one/other plurals | full | | Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full | | Russian | `ru` | 4,009 / 4,020 | full | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..1de4d67e55 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -888,6 +888,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Hindi terms (2026-11-13): every row that names a variable (data merge, variable fonts and widths, environment variables) says वेरिएबल, and joining paths, anchor points, point type and lines says जोड़ें instead of शामिल हों, which stays for joining the community; a test checks both terms in every row; scores and estimates unchanged. - Hindi English words (2026-11-13): 264 rows no longer keep English words in their sentences, repeat a parameter's name in Hindi or garble them, among them "unknown align", which now names the alignment; lists of accepted values such as `(left|right|top|bottom)` stay as their English writes them; two tests check every row; native review remains; scores and estimates unchanged. - Italian messages (2026-11-13): the Italian catalog (`it`) has 1,802 more status and error message rows, and the untranslated report's missing Italian messages go from 1,842 to 40; the translations are VectorCraft's own and await native review; scores and estimates unchanged. See [localization-parity.md](localization-parity.md). + - Italian messages no catalog had (2026-11-13): the Italian catalog has 126 more `@msg` rows, 3,560 in all: the image export, color profile, color book, swatch exchange file, DXF and DWG import, 3D and Revolve, script and zip errors that no other catalog translates yet, and the Banner Hems and Grommets, parameter range and recovery save messages that other catalogs translate. The untranslated report lists 54 Italian messages, which open #7116, #7211, #7378 and #7405 translate; scores and estimates unchanged. - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). From f9b1c2504f50a66cb1746a5fbb3d3cc9d60750d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:28:08 -0800 Subject: [PATCH 15/89] Smooth tool: measure twice the reach along a curve that leaves without a handle Near an anchor whose handle does not reach out, the curve starts more slowly than its control polygon, so the finely cut stretch could end before twice the reach and the turn there was measured along a long chord. The stretches past it are now cut as finely until the pieces reach three times the reach. docs/mcp.md says the one-eighth cap applies to a closed path the drag reaches all the way round. --- crates/engine/src/cmd/draw2/smooth.rs | 50 +++++++++++++++++++++++++-- docs/mcp.md | 4 +-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index a2b4f61bfe..a460cb3ed2 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -31,7 +31,8 @@ fn polygon_len(c: CubicBez) -> f64 { /// The places (0 to 1, in order, without 0 and with 1) on the curved segment `c` that /// [`smooth_corners`] measures it at: up to [`CORNER_PIECES`] evenly spaced, and near each end, over /// a stretch at least three times `reach` long where the segment is that long, up to as many more -/// that are no more than `reach` / 8 apart along the control polygon. The turns over `reach` and +/// that are no more than `reach` / 8 apart along the control polygon, and as many again in each +/// stretch twice as long after it until the pieces reach three times `reach`. The turns over `reach` and /// twice `reach` from an anchor are then measured along the curve however long the segment is. fn corner_places(c: CubicBez, reach: f64) -> Vec { let piece = reach / 8.0; @@ -41,7 +42,8 @@ fn corner_places(c: CubicBez, reach: f64) -> Vec { if piece > 0.0 { for from_start in [true, false] { // The stretch from the end: halved until its control polygon is at most six reaches long. - let mut t = 1.0 / k as f64; + let first = 1.0 / k as f64; + let mut t = first; let part = |t: f64| if from_start { c.subsegment(0.0..t) } else { c.subsegment(1.0 - t..1.0) }; for _ in 0..60 { if polygon_len(part(t)) <= 6.0 * reach { @@ -49,8 +51,34 @@ fn corner_places(c: CubicBez, reach: f64) -> Vec { } t /= 2.0; } + let side = |u: f64| if from_start { u } else { 1.0 - u }; let m = pieces(polygon_len(part(t))); - places.extend((1..m).map(|j| t * j as f64 / m as f64).map(|u| if from_start { u } else { 1.0 - u })); + let mut fine: Vec = (1..=m).map(|j| t * j as f64 / m as f64).collect(); + // Then the stretches twice as long in turn, cut as finely, up to the first even place, until + // the pieces reach three reaches from the end, so twice the reach is measured along the curve + // where a handle that does not reach out makes the curve start more slowly than its polygon. + let mut covered = 0.0; + let mut at = c.eval(side(0.0)); + for u in &fine { + let q = c.eval(side(*u)); + covered += at.distance(q); + at = q; + } + let (mut from, mut levels) = (t, 0); + while covered < 3.0 * reach && from < first && levels < 60 { + let to = (2.0 * from).min(first); + let piece_len = polygon_len(if from_start { c.subsegment(from..to) } else { c.subsegment(1.0 - to..1.0 - from) }); + let m = pieces(piece_len); + for j in 1..=m { + let u = from + (to - from) * j as f64 / m as f64; + let q = c.eval(side(u)); + covered += at.distance(q); + at = q; + fine.push(u); + } + (from, levels) = (to, levels + 1); + } + places.extend(fine.into_iter().map(side)); } } places.retain(|u| *u > 0.0 && *u <= 1.0); @@ -969,6 +997,22 @@ mod tests { assert_eq!(at_v(&long), Some(true)); } + /// A line that meets a curve leaving with no handle, turning by 58° at the anchor and by about + /// 57.53° over twice a reach of 0.02 pt: the anchor is a corner, measured either way along the path, + /// as the stretch measured finely near the anchor covers twice the reach along the curve. + #[test] + fn a_corner_next_to_a_curve_without_a_handle_is_kept() { + let at = 58f64.to_radians(); + let prev = Anchor::corner(Point::new(-100.0 * at.cos(), -100.0 * at.sin())); + let v = Point::new(0.0, 0.0); + let a = Anchor::with_handles(v, v, v); + let end = Point::new(600.0, 600.0); + let next = Anchor::with_handles(end, Point::new(200.0, 0.0), end); + assert_eq!(smooth_corners(&[prev, a, next], false, 0.02), vec![false, true, false]); + let back = |x: Anchor| Anchor::with_handles(x.p, x.h_out, x.h_in); + assert_eq!(smooth_corners(&[back(next), back(a), back(prev)], false, 0.02), vec![false, true, false]); + } + /// A jagged closed path whose length is less than twice the reach: its jags are not corners, /// while a triangle that small keeps its three. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index d30f2bbf8a..13dd1919ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5296,8 +5296,8 @@ path, the curve keeps the direction of the part outside the drag. `object.path.s direction. `path.smoothRegion` keeps an anchor as a corner where the path turns by more than 57.5° (an angle inside the corner -under 122.5°) at the anchor and also over `radius` and over twice `radius` on either side of it (on a closed path, no -more than an eighth of its length), so a jag or a bump smaller than `radius` is still smoothed. The stretches on either side of a kept corner are smoothed and refitted on their +under 122.5°) at the anchor and also over `radius` and over twice `radius` on either side of it (on a closed path that the drag reaches all the +way round, no more than an eighth of its length), so a jag or a bump smaller than `radius` is still smoothed. The stretches on either side of a kept corner are smoothed and refitted on their own, and the corner keeps its place and the directions the path arrives and leaves along, on every call and at any `fidelity`. A call that changes no anchor returns `{ids: [], before: 0, after: 0}` and adds no undo step. The 57.5° threshold is VectorCraft's own value. From dd6ab903e7938b2848e903ae3cdc93186bf48dab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:32:36 -0800 Subject: [PATCH 16/89] 3D: Expand as Wireframes measures later effects with the cast shadow A geometry effect after an object's 3D effect, such as a Transform that scales or rotates about the center, measures the drawn art, cast shadow included. Expand as Wireframes measured the faces alone, so with a shadow on, the expanded edges landed away from the surfaces on the canvas. The faces are now reshaped together with the shadow the canvas draws behind them (Extrude & Bevel, Inflate, Rotate and Revolve, on an object, group or layer), and only the faces are kept. --- crates/effects/src/revolve.rs | 24 ++++---- crates/effects/src/solids.rs | 14 +++-- crates/effects/src/solids/wireframe.rs | 61 +++++++++++++++----- crates/effects/src/solids/wireframe/tests.rs | 26 +++++++++ 4 files changed, 96 insertions(+), 29 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 74b23340cd..e626c757a7 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -634,9 +634,10 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// instances, blends and clipping groups already resolved), in document coordinates: with the /// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of /// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for -/// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in -/// after a fill's or stroke's Revolve are looked up in `doc`. -pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { +/// each member, about one shared axis. With the effect on the object, group or layer, also the +/// shadow drawn behind them. The swatches and patterns named by a live-effect plug-in after a fill's +/// or stroke's Revolve are looked up in `doc`. +pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result { let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) }; @@ -647,7 +648,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node) -> Result, n: &Node, fx: &Effect, shapes: &[Profile]) -> let art = surfaces_for(doc, n, fx, shapes)?; // A shadow past the limits draws the source path, as a solid past them does; commands report // the error ([`validate_solid_in`]). - let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? { - Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)), - None => vec![], - }; + let shadow = shadow_for(n.id, fx, shapes).ok()?; Some((shadow, art)) } +/// The shadow [`art_for`] draws for effect `fx` on `shapes` behind the surfaces of object `id`: +/// none with the shadow off, and an error when it is past the limits. +pub(crate) fn shadow_for(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Result>, String> { + Ok(match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes)? { + Some((caster, settings, cast)) => shadow::scoped_art(id, &caster, settings, cast, has_masks(shapes)), + None => vec![], + }) +} + /// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on /// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the /// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index e6e7d89ab5..9d256ee4fc 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -53,8 +53,8 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node, faces: Vec, revolve: bool) -> Vec { +/// ([`reshaped_beside`]): its first visible Revolve when `revolve` is set, else its first visible +/// Extrude & Bevel, Inflate or Rotate. The effects measure the faces together with `shadow`, the +/// shadow the canvas draws behind them, as they measure the drawn art. +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, shadow: Vec>, revolve: bool) -> Vec { let fx = &n.appearance.effects; let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) }; let Some(index) = drawn else { return faces }; - reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) + reshaped_beside(faces, shadow, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) } /// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as @@ -78,29 +79,60 @@ fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, revolv /// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform, /// and the document. pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { + reshaped_beside(faces, vec![], later, ctx) +} + +/// The name the faces carry through [`reshaped_beside`], which tells them from the shadow. +const FACE: &str = "face"; + +/// [`reshaped`] with `shadow`, the shadow drawn behind the faces, in the art the effects reshape: +/// each effect measures the faces and the shadow together, and only the faces are returned. +fn reshaped_beside(faces: Vec, shadow: Vec>, later: &[Effect], ctx: &GeomContext) -> Vec { let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); if after.is_empty() { return faces; } let id = NodeId(ctx.seed); let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); - let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect()); + let face = |f: PathData| { + let mut face = Node::path(id, f, fill.clone()); + face.name = Some(FACE.into()); + Arc::new(face) + }; + let mut art = Node::group(id, shadow.into_iter().chain(faces.into_iter().map(face)).collect()); crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); let mut out = vec![]; - art.walk(&mut |c| out.extend(c.path_data().cloned())); + face_paths(&art, false, &mut out); out } +/// The paths of the faces in `n` ([`FACE`]), `inside` one or not: a face that a Transform effect +/// copies or a live-effect plug-in cuts into pieces becomes a group with its name. +fn face_paths(n: &Node, inside: bool, out: &mut Vec) { + let inside = inside || n.name.as_deref() == Some(FACE); + if inside { + out.extend(n.path_data().cloned()); + } + for c in n.children().into_iter().flatten() { + face_paths(c, inside, out); + } +} + +/// The faces of a 3D effect on an object, group or layer itself, and the shadow the canvas draws +/// behind them (none with the shadow off, or with the effect on fills and strokes). +pub(crate) type FacesAndShadow = (Vec, Vec>); + /// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect /// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer, -/// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect -/// plug-in after a fill's or stroke's 3D effect are looked up in `doc`. -fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { +/// the solids of its members, about one shared axis. With the effect on the object, group or layer, +/// also the shadow drawn behind them. The swatches and patterns named by a live-effect plug-in after +/// a fill's or stroke's 3D effect are looked up in `doc`. +fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result { if is_container(n) { let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; let mut shapes = members(n, label_of(&fx.id))?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); - return facet_paths(fx, &shapes); + return Ok((facet_paths(fx, &shapes)?, super::shadow_for(n.id, fx, &shapes)?)); } let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); let (path, rule) = object_profile(n, label)?; @@ -108,7 +140,8 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S let (sources, paints) = solid_paint_sources(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); - return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); + let shapes = [Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]; + return Ok((facet_paths(fx, &shapes)?, super::shadow_for(n.id, fx, &shapes)?)); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; @@ -119,7 +152,7 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } } - Ok(out) + Ok((out, vec![])) } /// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 792fbdb28a..5835503451 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -192,6 +192,32 @@ fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { } } +/// A Transform effect after the 3D effect scales the faces about the center of the drawn art with +/// its shadow, as it scales the surfaces on the canvas. +#[test] +fn a_later_transform_measures_the_faces_with_their_shadow() { + for id in [super::super::EXTRUDE, crate::REVOLVE] { + let scale = crate::new_effect("distort.transform", &json!({"scaleH": 50.0, "scaleV": 50.0})).unwrap(); + let mut n = solid_square(id, json!({"shadow": true, "shadowDistance": 100})); + n.appearance.effects.push(scale.clone()); + let wire = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}"))); + let art = if id == crate::REVOLVE { crate::revolve_art(&n) } else { super::super::solid_art(&n) }.unwrap(); + let mut drawn = art.clone(); + let fx: Vec<&Effect> = art.appearance.effects.iter().collect(); + crate::reshape::reshape_leaves(&mut drawn, &fx, &art.appearance.effects, None); + // The shadow is the art's first member; the surfaces follow it. + let surfaces: Vec = drawn.children().unwrap().iter().skip(1).flat_map(|c| face_paths(c)).collect(); + let a = bounds(&surfaces); + for (x, y) in [(wire.x0, a.x0), (wire.y0, a.y0), (wire.x1, a.x1), (wire.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{id}: {wire:?} {a:?}"); + } + // Without the shadow, the faces are scaled about their own center. + let mut alone = solid_square(id, json!({})); + alone.appearance.effects.push(scale); + assert!((bounds(&three_d_wireframe_in(None, &alone).unwrap()).x0 - wire.x0).abs() > 1.0, "{id}"); + } +} + /// With two 3D effects, a geometry effect between them reshapes the faces of the one drawn only /// once: Revolve is drawn, so a Transform before it moves its profile and not its faces again. #[test] From 2610d97cd43dfc99d7126418e79ad1d97ff9b39f Mon Sep 17 00:00:00 2001 From: datatimp <12644819+datatimp@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:41:25 -0500 Subject: [PATCH 17/89] Layers: a plain click on another row lets go of the selected guide (#7340) --- crates/ui-egui/src/panels/layers/rows.rs | 8 ++++- crates/ui-egui/src/panels/layers/tests.rs | 42 +++++++++++++++++++++++ docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 9f80d93791..9e8601131c 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -81,7 +81,9 @@ pub(super) fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) { } /// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift -/// (highlight the rows from the last one clicked down to it). +/// (highlight the rows from the last one clicked down to it). A plain click also lets go of the +/// selected ruler guides, keeping the selected art (#7340): a guide's row picks it the way a +/// highlight picks a row, so picking another row alone drops it. pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) { let Some((id, m)) = out.click.take() else { return }; let anchor: Option = ui.data(|d| d.get_temp(key("anchor"))); @@ -95,6 +97,10 @@ pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) { widgets::apply_row_click(&mut rows, &click); out.rows_after_click = Some(rows); let ids = |rows: &[NodeId]| rows.iter().map(|r| r.0).collect::>(); + if matches!(click, widgets::RowClick::Only(_)) && !view.guides.is_empty() { + // Toggled out of the selection, so the selected art stays. + out.actions.push(("guide.select".into(), json!({"indexes": view.guides, "toggle": true}))); + } out.actions.push(match click { widgets::RowClick::Only(id) => ("layer.setCurrent".into(), json!({"id": id.0})), widgets::RowClick::Toggle(id) => ("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"})), diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 55fa27ce58..c0542d4341 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -987,6 +987,48 @@ fn guide_rows_are_deleted_with_the_trash_and_delete() { assert!(kept(&app)); } +/// A plain click on another row lets go of the guide a guide row selected, keeping the selected +/// art, so a guide can be dropped without Ctrl/Cmd-clicking its own row (#7340); a Ctrl/Cmd click +/// on a row adds to the pick and keeps it. +#[test] +fn a_plain_row_click_lets_go_of_the_selected_guide() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap()); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 1, its guide (no target circle), the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 2); + let guide_row = egui::pos2(150.0, (circles[0].y + circles[1].y) / 2.0); + let rect_row = egui::pos2(150.0, circles[1].y); + let guides = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides) + }; + let art = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); + // Guide, then the rectangle's row: the guide is let go and the rectangle's row highlighted. + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + assert_eq!(guides(&app), [0]); + click_with(&mut app, &ctx, rect_row, egui::Modifiers::NONE); + assert!(guides(&app).is_empty(), "a plain row click drops the guide"); + assert_eq!(app.session.active().unwrap().highlighted_rows(), [rect]); + // Art and a guide selected together: a plain row click drops only the guide. + run(&mut app, "select.all", json!({})); + click_with(&mut app, &ctx, guide_row, egui::Modifiers::SHIFT); + assert_eq!((guides(&app), art(&app)), (vec![0], vec![rect])); + click_with(&mut app, &ctx, egui::pos2(150.0, circles[0].y), egui::Modifiers::NONE); + assert!(guides(&app).is_empty()); + assert_eq!(art(&app), [rect], "the selected art stays"); + // A Ctrl/Cmd click on a row adds that row to the highlight and keeps the guide. + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + click_with(&mut app, &ctx, rect_row, egui::Modifiers::COMMAND); + assert_eq!(guides(&app), [0], "a Ctrl/Cmd row click keeps the guide"); +} + /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows /// holding selected art). fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..1b83d1e8b8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1165,7 +1165,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle in degrees as its value: a click or a drag in it points the Angle at the pointer and gives it the keyboard focus, Tab reaches it after the Roundness field, and the arrow keys step the Angle by 1° (10° with Shift, Home and End to −180° and 180°). The Blob Brush Tool Options also have Fidelity, which the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list as setting how far the pointer moves before the path gets a new anchor point. The range and default are VectorCraft's own values: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`). A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`). -- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. A guide's row has its own eye and lock, which hide or lock that guide alone in one undo step and take part in a drag down either column ([help page](https://helpx.adobe.com/in_hi/tutorial-builder/user-guide/illustrator-tutorial-builder/-ai--examples-of-common-coach-marks.html) lists a Guide among the Layers panel's items); a hidden guide is not drawn and does not snap, a locked one is drawn and snaps but can't be selected, moved or deleted, and its own state is kept through save and reopen and while its layer is hidden or locked (`layer.setProps` on its `guide.list` id). Object › Lock › Selection and Object › Hide › Selection lock or hide a selected guide with the selected art, and Unlock All and Show All unlock or show such guides and select them ([help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) for Lock and Hide Selection). A guide row dropped on the panel's trash deletes its guide, the trash deletes a guide its row selected, and after a click on a guide row Delete deletes the guide rather than the rows highlighted before ([help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) for deleting a selected guide). Not done: guides stacked among the art of their layer (they are listed above it). Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. +- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. A guide's row has its own eye and lock, which hide or lock that guide alone in one undo step and take part in a drag down either column ([help page](https://helpx.adobe.com/in_hi/tutorial-builder/user-guide/illustrator-tutorial-builder/-ai--examples-of-common-coach-marks.html) lists a Guide among the Layers panel's items); a hidden guide is not drawn and does not snap, a locked one is drawn and snaps but can't be selected, moved or deleted, and its own state is kept through save and reopen and while its layer is hidden or locked (`layer.setProps` on its `guide.list` id). Object › Lock › Selection and Object › Hide › Selection lock or hide a selected guide with the selected art, and Unlock All and Show All unlock or show such guides and select them ([help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) for Lock and Hide Selection). A guide row dropped on the panel's trash deletes its guide, the trash deletes a guide its row selected, and after a click on a guide row Delete deletes the guide rather than the rows highlighted before ([help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) for deleting a selected guide). A plain click on another row lets go of a guide its row selected, keeping the selected art, as a plain click on a row picks that row alone; a Ctrl/Cmd or Shift click keeps it (#7340, `panels::layers` test `a_plain_row_click_lets_go_of_the_selected_guide`). Not done: guides stacked among the art of their layer (they are listed above it). Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. - **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (layers dropped in the empty space under the last row go below the bottom layer, #2260; dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Collect for Export (As Single Asset, As Multiple Assets), Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Right-clicking a layer, sublayer, group or object row offers Collect for Export for that row, or for every highlighted row when it is one of them; guide and blend spine rows have no context menu. Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). With Paste Remembers Layers on, art copied from a sublayer pastes back into that sublayer, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=223) describes art going back to the layer it was copied from: in the same document while the sublayer is still there, two sublayers of the same name in different layers kept apart; in another document by the names of the sublayer and the layers around it. These rules are VectorCraft's own: a missing sublayer is made inside its layer (a missing layer on top, with the sublayer inside it), in the paste's one undo step; of layers with the same names, the topmost chain that matches the most names wins, a shown and unlocked one first; a matched layer that is hidden or locked, or inside one that is, refuses the paste, and nothing is pasted. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62a99726a2..e320e11298 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -250,7 +250,7 @@ tools, never checked side by side) isn't captured row by row. | A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` | | Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | | Ruler origin: corner drag with preview, snapped as a drawn point is (Smart Guides, Snap to Point and the ruler guides, pixels, the grid), double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | -| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer; each row's own eye and lock; a row dropped on the trash deletes its guide | done | `guide.setLayer`; guides from older files go on a layer; `layer.setProps` on a guide's id hides or locks it alone; Object › Lock/Hide › Selection, Unlock All and Show All reach guides; `guide.remove {indexes}` | +| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer; each row's own eye and lock; a row dropped on the trash deletes its guide; a plain click on another row lets go of the guide | done | `guide.setLayer`; guides from older files go on a layer; `layer.setProps` on a guide's id hides or locks it alone; Object › Lock/Hide › Selection, Unlock All and Show All reach guides; `guide.remove {indexes}`; #7340 | | Objects made into guides: drawn in the guide colour and style of Preferences; Lock Guides locks them too (no select, move or delete); a dragged selection snaps to ruler guides with Smart Guides on, by the nearest point of the path when pressed on its stroke or beside its edge, so the path lands exactly on the guide | done | #1534 (#1543, #1583); #3701 | | A selected ruler guide's X or Y in the Transform panel; typing a value moves it | not scored | #1659 (part of #1597); several guides move together; not in Properties or the Control bar. Not established by the documentation we could find: the help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) don't describe it, and the one public statement is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. Kept as a convenience pending the owner's decision; no parity claim | | Alt swaps a ruler guide's orientation while dragging | missing | Alt-drag copies the selected guides ([Use rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html)); `guide.move` `{copy: true}` | From cf7a72e21bae2c895672d5503cb2b4bf19c0a29d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:44:35 -0800 Subject: [PATCH 18/89] Type: the Single-line Composer measures a line's room across its tallest character In area type of a shape other than a rectangle, or with text wrap, the Single-line Composer placed each line at the metrics of its first character and broke it in the room there. When a later character on the line was taller, the baseline moved down by the difference without measuring the room again, so the line could reach into a narrower part of the frame or into a wrap object. - A line that turns out taller than it was placed is placed again at metrics that hold both and broken again in the room there. - When that leaves the taller character for the next line, the line is set at its own metrics where the room there is no narrower, and takes the words before that character that fit there. Otherwise it stays where it was placed. Tests: crates/text/src/tests/combos.rs (a stepped frame and text wrap; the two mixed-size tests now expect the Single-line Composer to break as the Every-line Composer does there). Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/text/src/layout/flow.rs | 72 +++++++++++++++++++++++++++++---- crates/text/src/tests/combos.rs | 41 +++++++++++++++++-- docs/mcp.md | 4 ++ docs/target-app-parity.md | 2 +- 4 files changed, 107 insertions(+), 12 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 1ee9a3fe50..2771c26954 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -24,6 +24,10 @@ fn pushes_in(para: &ParaStyle) -> bool { /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; +/// The widths tried, halving the difference each time, for the longest break of a line placed again +/// at its own metrics in more room than it was broken in ([`place_flow_line`]). +const REFIT_STEPS: usize = 24; + /// Whether every line of area type in `regions` is as wide however tall it is and wherever it is set: /// rectangular cells without text wrap. fn constant_width(regions: Option<&[Region]>) -> bool { @@ -415,25 +419,31 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let word = word_width(sg, *i, line_end, para.kinsoku); // A composed line in a frame whose lines are not all as wide is placed at its own tallest // metrics, as the composer placed it (`shaped::Shaped`): its room is the room across its height. - let est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + let composed_line = composed.as_ref().and_then(|lines| lines.get(*para_line)).is_some_and(|&(end, _)| end > *i); + let mut est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, _)) if end > *i && !constant_width(pen.regions) => sg.get(*i..end).and_then(Metrics::max).unwrap_or(est), _ => est, }; + // A line broken here in such a frame is placed again when it turns out taller than it was + // placed: its room is the room across its own height. + let refit = !composed_line && pen.regions.is_some() && !constant_width(pen.regions); // Place, break, then settle the baseline on the line's real metrics (moving on to the // next row/column if it no longer fits). loop { + let before = refit.then(|| pen.clone()); let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { + let Some((mut baseline, mut x0, mut x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { cx.out.overflow = cx.text.len() > if *i < n { sg[*i].byte } else { pr.start }; return None; }; - let width = x1 - x0 - ind_l - ind_r; let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((¶.hyphenation, *zone)); - let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + let glyphs: &[SGlyph] = sg; + // The line broken in room `width` wide: its end, whether it ends with a hyphen, and whether + // it pushed characters in. + let break_in = |width: f64| match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { let start = *i; - let glyphs: &[SGlyph] = sg; // A horizontal line with right-to-left text is drawn in another order than its // text's, and its tabs take their stops in the order they are drawn: whether the line // ending before glyph `end` fits is measured as it is drawn ([`drawn_width`]). @@ -478,8 +488,56 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC } _ => (n, false, false), }; - let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); - baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { + let (mut end, mut hyph, mut pushed_in) = break_in(x1 - x0 - ind_l - ind_r); + let metrics = |end: usize| glyphs.get(*i..end).and_then(Metrics::max).unwrap_or(*pm); + let m = metrics(end); + // The baseline moves from where `est` placed it to where the line's metrics set it, unless + // the line was placed at metrics of its own (below). + let mut settle = true; + if let Some(before) = before + && m != est + { + if m.union(est) != est { + // Taller than it was placed: place it again at metrics that hold both, and break it + // again in the room there. + *pen = before; + est = est.union(m); + continue; + } + // Broken again in the room of a taller line, it no longer holds what made that line + // taller. It goes where its own metrics place it when the room there holds the room it + // was broken in, and otherwise stays where it was placed, inside the room measured. + settle = false; + let mut own = before; + if let Some((b, a, z, _)) = own.place(m, ind_l, ind_r, word) + && a <= x0 + EPS + && z >= x1 - EPS + { + // The room there may hold more of the line, up to what would make it taller than + // `m`: the longest break that does not is between the two widths (a line breaks no + // earlier in more room). + let (mut lo, mut hi) = (x1 - x0 - ind_l - ind_r, z - a - ind_l - ind_r); + let wider = break_in(hi); + if metrics(wider.0).union(m) == m { + (end, hyph, pushed_in) = wider; + } else { + for _ in 0..REFIT_STEPS { + let mid = (lo + hi) / 2.0; + let line = break_in(mid); + if metrics(line.0).union(m) == m { + (end, hyph, pushed_in) = line; + lo = mid; + } else { + hi = mid; + } + } + } + *pen = own; + (baseline, x0, x1) = (b, a, z); + } + } + let m = metrics(end); + baseline += if !settle || same_baseline || (pen.regions.is_none() && first_in_region) { 0.0 } else if pen.model == LeadingModel::EmBoxTop { // The em box top is fixed (the frame's top, or the line above's): the baseline diff --git a/crates/text/src/tests/combos.rs b/crates/text/src/tests/combos.rs index 0146871265..26e8f8d63a 100644 --- a/crates/text/src/tests/combos.rs +++ b/crates/text/src/tests/combos.rs @@ -378,8 +378,8 @@ fn x_width() -> f64 { fn composer_x_mixed_sizes_in_a_stepped_frame() { // A frame as wide as the first line down to 40 pt and half as wide below. A 36 pt word makes // the line it is on reach below the step, where the frame is narrow: the every-line composer - // breaks the paragraph with each line's measure where its height sets it, not as the - // single-line composer does, and every line keeps within its room. + // breaks the paragraph with each line's measure where its height sets it, and every line keeps + // within its room. let w = x_width(); let set = |x: CharStyle, composer: Composer| { let mut t = stepped(w, w * 0.5, 40.0, 300.0); @@ -394,8 +394,10 @@ fn composer_x_mixed_sizes_in_a_stepped_frame() { // The 36 pt line reaches below the step and is set at the narrow measure. let tall = &l.lines[1]; assert!(tall.ascent > 30.0 && tall.baseline + tall.descent > 40.0 && tall.avail.1 < w * 0.5 + 0.01, "{:?}", l.lines); + // The single-line composer measures each line across its height too, and breaks this + // paragraph the same way. let (greedy, _) = set(sans(36.0), Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); // Uniform sizes keep the breaks they had. let (uniform, _) = set(sans(12.0), Composer::EveryLine); assert_eq!(uniform, ["Destroy all creatures with toughness X or", "less. Then draw a card for each creature", "destroyed this way."]); @@ -435,8 +437,10 @@ fn composer_x_mixed_sizes_around_text_wrap() { across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0 }); assert_eq!(hits.count(), 0, "{:?}", l.lines); + // The single-line composer measures each line across its height too, and breaks this + // paragraph the same way. let (greedy, _, _) = set(Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); } #[test] @@ -463,6 +467,35 @@ fn composer_x_mixed_sizes_in_a_frame_that_widens() { assert_eq!(greedy, ["Destroy all", "creatures", "with toughness X or less. Then draw", "a card for each creature destroyed this", "way."]); } +#[test] +fn single_line_composer_measures_a_line_across_its_tallest_glyph() { + // A frame wide enough for "Destroy all creatures X" down to 20 pt and as wide as "Destroy all" + // below, and a rectangle as wide with an object (6 pt wrap offset) beside that part. The first + // line starts with 12 pt words and takes in a 36 pt word, which sets it lower and reaches below + // 20 pt: the single-line composer measures its room across that height, every line that + // reaches the narrow part is set in it, and the first line keeps the 12 pt words that fit + // above it. + let w = natural_width("Destroy all creatures X", 12.0) + 30.0; + let narrow = natural_width("Destroy all", 12.0) + 2.0; + let runs = vec![run("Destroy all creatures ", sans(12.0)), run("X", sans(36.0)), run(" or less. Then draw a card.", sans(12.0))]; + let object = Rect::new(narrow + 6.0, 26.0, w + 10.0, 300.0); + let mut beside = area(runs.clone(), Rect::new(0.0, 0.0, w, 300.0)); + beside.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }]; + let mut shaped = stepped(w, narrow, 20.0, 300.0); + shaped.runs = runs; + for (name, mut t) in [("stepped frame", shaped), ("text wrap", beside)] { + t.para.composer = Composer::SingleLine; + let l = layout(db(), &t); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{name}: {:?}", l.lines); + let low = l.lines.iter().filter(|li| li.baseline + li.descent > 20.01); + assert!(low.clone().all(|li| li.avail.1 <= narrow + 0.01), "{name}: {:?}", l.lines); + // The 36 pt word is set, on a line in the narrow part. + assert!(low.clone().any(|li| li.ascent > 30.0), "{name}: {:?}", l.lines); + assert_eq!(line_texts(&t, &l)[0], "Destroy all creatures", "{name}: {:?}", l.lines); + assert!(l.lines[0].baseline < 20.0 && l.lines[0].avail.1 > w - 0.01, "{name}: {:?}", l.lines); + } +} + #[test] fn composer_x_mixed_sizes_overflow_a_shaped_frame() { // A stepped frame too short for the paragraph: the lines that fit are set, in order, each once diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..d42836a60f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10448,6 +10448,10 @@ where the lines above it put it, across the line's own height, so a taller line narrower, or beside a wrap object, gets the room there. Such a paragraph falls back to Single-line when the frame has no room for all of it, or when the search for its breaks passes VectorCraft's own limits (1,000,000 lines tried and 2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the paragraphs of a frame). +Single-line measures each line's room the same way: a line that takes in a taller character is placed again across its +new height and broken again in the room there. When that leaves the taller character for the next line, the line is +set at its own height where the room there is no narrower, with the words before that character that fit there, and +otherwise stays where it was placed. The UI command `type.lineComposer` (Cmd+Alt+Shift+C, in remote mode `press_key {key: "C", mods: {cmd: true, alt: true, shift: true}}` or `invoke_menu`) sets `singleLine` when every paragraph of the selected type (or every paragraph the Type tool's selection touches) uses Every-line, else `everyLine`, and returns the composer it set. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..6b5933cbdc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1299,7 +1299,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the font filters every font menu shares (one chosen in the Glyphs panel's menu or a dialog's applies too) but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). - The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. Such a paragraph is broken with the Single-line Composer when the frame has no room for all of it or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html). + The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. Such a paragraph is broken with the Single-line Composer when the frame has no room for all of it or when the search passes VectorCraft's own limits. The Single-line Composer measures a line's room across the height of its tallest character too: a line that takes in a taller character is placed again and broken again in the room there. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html). A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`). In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). In area type whose frame has several contours, a line runs across an edge two contours share only when their edges lie exactly on one line, so a gap between contours keeps lines from crossing it, even a gap one unit in the last place wide near the edge of the canvas; an edge divided by an anchor exactly on it still lets lines cross (`layout::region::separate_slanted_contours_keep_a_four_ulp_gap_near_canvas_limit`, `shared_slanted_edge_still_joins_near_canvas_limit`, `area_type_sets_no_line_across_a_four_ulp_gap_between_slanted_contours`). From edee68e3d1ee2e3e01fbc8ea91e8027f55e55ee7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:42:22 -0800 Subject: [PATCH 19/89] EPS import: Ryumin-Light-H and GothicBBB-Medium-V read as Japanese type without their CIDFont MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name ending in the row-and-cell CMap H or V split only when the file defined the CIDFont or a face of that name was installed, so /Ryumin-Light-H findfont in a file that only names it read its strings byte by byte as a plain font: <467c> came in as "F|" instead of 日. Ryumin-Light and GothicBBB-Medium, the Japanese CIDFonts a printer carries (Adobe Technical Note #5014, appendix A.2), now split at H or V as well. Latin names such as Foo-Bold-H still stay whole. --- crates/eps/src/import/tests_cid.rs | 21 +++++++++++++++++---- crates/eps/src/import/text.rs | 11 +++++++---- docs/file-format-parity.md | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 8350cd514b..83691e506a 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -638,10 +638,11 @@ fn named_cid_type_in_charpath_cshow_and_glyphshow() { } /// A font name ending in the row-and-cell CMap `H` or `V` stays a font of the whole name, its -/// strings read byte by byte, unless the part before the CMap names a CIDFont the file defines (or -/// an installed face that covers Japanese). Type in a predefined CMap whose mappings aren't -/// available is left out with a warning, each code moving the point one em, and a long string in a -/// CID-keyed font over a predefined CMap shown in a loop runs out of the budget. +/// strings read byte by byte, unless the part before the CMap names a CIDFont the file defines, a +/// Japanese CIDFont a printer carries (or an installed face that covers Japanese). Type in a +/// predefined CMap whose mappings aren't available is left out with a warning, each code moving +/// the point one em, and a long string in a CID-keyed font over a predefined CMap shown in a loop +/// runs out of the budget. #[test] fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { for (font, family, style) in [("Futura-H", "Futura", "H"), ("Foo-Bold-H", "Foo", "Bold-H"), ("Foo-Condensed-V", "Foo", "Condensed-V")] { @@ -665,6 +666,18 @@ fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { let r = super::tests::read(&format!("{define} findfont 20 scalefont setfont 10 20 moveto <467c> show")); assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["日"], "{define}: {:?}", r.warnings); } + // The Japanese CIDFonts a printer carries (Adobe Technical Note #5014, appendix A.2) split at + // `H` or `V` without the file defining them or a face of their name being installed. A face + // with the whole name, when installed, is that face's. + for (font, vertical) in [("Ryumin-Light-H", false), ("GothicBBB-Medium-V", true)] { + if vectorcraft_text::FontDb::global().find_postscript(font).is_some() { + continue; + } + let r = super::tests::read(&format!("/{font} findfont 20 scalefont setfont 10 20 moveto <467c> show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日"], "{font}: {:?}", r.warnings); + assert_eq!(t[0].vertical, vertical, "{font}"); + } let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill"); assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 515e0b12ec..5f067e7fcb 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -509,9 +509,11 @@ impl Interp<'_> { /// Font name `name` as a CIDFont's name and the name of a predefined CMap other than a Unicode /// CMap ([`super::predefined_cmap::composite_font`]), unless an installed face has the whole /// name. A name that ends in the row-and-cell CMap `H` or `V` splits only when the part before - /// it names a CIDFont the file defines, or has a hyphen and names an installed face that covers - /// Japanese; otherwise it stays a font of the whole name (`Futura-H`, `Foo-Bold-H`), its - /// strings read byte by byte. + /// it names a CIDFont the file defines, one of the Japanese CIDFonts a printer carries + /// (`Ryumin-Light`, `GothicBBB-Medium`: Adobe Technical Note #5014, appendix A.2, + /// ), or has a + /// hyphen and names an installed face that covers Japanese; otherwise it stays a font of the + /// whole name (`Futura-H`, `Foo-Bold-H`), its strings read byte by byte. fn predefined_split<'a>(&mut self, name: &'a str) -> Option<(&'a str, &'a str)> { let (cid_font, cmap) = super::predefined_cmap::composite_font(name)?; let db = vectorcraft_text::FontDb::global(); @@ -525,7 +527,8 @@ impl Interp<'_> { let defined = self.instances(&Obj::name("CIDFont")).is_ok_and(|d| d.borrow().contains_key(&key)) || cid_font_in(&self.fonts); // 日 is in the row-and-cell CMaps' character set (JIS X 0208). let installed = || db.find_postscript(cid_font).is_some_and(|face| face.covers('日')); - if !(defined || (cid_font.contains('-') && installed())) { + let printer = matches!(cid_font, "Ryumin-Light" | "GothicBBB-Medium"); + if !(defined || printer || (cid_font.contains('-') && installed())) { return None; } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 92fd10cbb3..a728cb6d36 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -281,7 +281,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, the CIDFont is `Ryumin-Light` or `GothicBBB-Medium` (the Japanese CIDFonts a printer carries), or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | DCS 1.0 / 2.0 | place linked | place linked only (one image of the plates; spot plates as spot swatches; the composite's clipping path; each plate on its own ink in Separations Preview and print separations) | no | `crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | From cf250db2e8b70185b9f9b66f14363942ee76a345 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 21:05:44 -0800 Subject: [PATCH 20/89] Paths: Reverse Path Direction turns the stroke's width profile and pen records with the path Reverse Path Direction reversed the anchors but left the stroke's width profile, pressure, tilt, bearing and rotation running the old way, so a tapered or pressure-drawn stroke changed shape. They now turn with the path through the blend code's stroke reorientation (the width profile's sides swap), and the stroke draws as before. A path whose subpaths do not all turn alike keeps its stroke as it was, as blends do. Arrowheads stay at the path's start and end. --- crates/doc/src/blend.rs | 47 ++++++++++++++++++----- crates/engine/src/cmd/path.rs | 30 ++++++++------- crates/engine/src/tests/attributes.rs | 55 +++++++++++++++++++++++++++ docs/mcp.md | 3 +- 4 files changed, 111 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index b84de1cac3..56764b6426 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -606,9 +606,8 @@ fn reoriented<V: Copy>(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wr out } -/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap -/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its -/// arrowheads. +/// A stroke read along its subpath turned as `o` says: its width profile (whose sides swap when +/// reversed), pen pressure, tilt, bearing and rotation. Its arrowheads stay as they are. fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { if o.is_identity() { return; @@ -648,15 +647,45 @@ fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same); p.points = out; } - if o.reversed && !o.closed { - std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); - s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); +} + +/// `n` with every stroke read along its subpaths turned as `o` says, and on a reversed open path +/// its arrowheads swapped. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o); + if o.reversed && !o.closed { + std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); + s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); + } + } } } -/// `n` with every stroke read along its subpaths turned as `o` says. -fn reorient_strokes(n: &mut Node, o: Orient) { - if o.is_identity() { +/// Turn the strokes of path `n` for Reverse Path Direction, before its subpaths flagged in +/// `reversed` are reversed with [`SubPath::reverse`] (a closed one then starts at its last +/// anchor): each stroke's width profile (sides swapped), pen pressure, tilt, bearing and rotation +/// then run the new way, so the stroke draws as it did. A stroke reads one profile along every +/// subpath, so it turns only when all of them turn alike. Arrowheads stay at the path's start and +/// end. +pub fn reverse_stroke_records(n: &mut Node, reversed: &[bool]) { + let NodeKind::Path { path, .. } = &n.kind else { return }; + let mut turns = path.subpaths.iter().enumerate().map(|(i, sp)| { + let mut o = Orient::of(sp); + if reversed.get(i).copied().unwrap_or(false) { + if sp.closed { + o.start_at(sp, sp.anchors.len().saturating_sub(1)); + } + o.reverse(); + } + o + }); + let Some(o) = turns.next() else { return }; + if o.is_identity() || !turns.all(|t| t.same_turn(&o)) { return; } for it in n.appearance.items.iter_mut() { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 33bde936bf..34f41eda24 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reverse Path Direction", ["Object", "Path"], None, - "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", + "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}", has_selected_path, reverse ), @@ -462,22 +462,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> { let mut changed = 0; for id in &ids { let path = path_mut(d, *id)?; - if let Some(si) = subpath { - if let Some(sp) = path.subpaths.get_mut(si) { + let flip: Vec<bool> = match subpath { + Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(), + None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(), + }; + if !flip.contains(&true) { + continue; + } + // The stroke's width profile and pen recordings turn with the path, read before it turns. + if let Some(n) = d.node_mut(*id) { + vectorcraft_doc::blend::reverse_stroke_records(n, &flip); + } + let path = path_mut(d, *id)?; + for (sp, f) in path.subpaths.iter_mut().zip(flip) { + if f { sp.reverse(); - changed += 1; - } - } else { - let flip: Vec<bool> = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(); - if flip.contains(&true) { - for (sp, f) in path.subpaths.iter_mut().zip(flip) { - if f { - sp.reverse(); - } - } - changed += 1; } } + changed += 1; } Ok(changed) })?; diff --git a/crates/engine/src/tests/attributes.rs b/crates/engine/src/tests/attributes.rs index d3edf2615b..8ddfc352a1 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -163,6 +163,61 @@ fn reverse_path_direction_on_and_off_set_the_winding() { assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse"); } +#[test] +fn reverse_path_direction_turns_the_width_profile_and_pen_records_with_the_path() { + use vectorcraft_doc::{AngleProfile, PressureProfile, WidthProfile}; + let mut s = session(); + let open = + NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 50}, {"x": 40, "y": 50}, {"x": 100, "y": 50}]}))["id"].as_u64().unwrap()); + let closed = rect(&mut s, 10.0); + let profile = WidthProfile { points: vec![(0.0, 0.2, 0.6), (0.3, 1.0, 0.4), (1.0, 0.5, 0.9)] }; + let pressure = PressureProfile { points: vec![(0.0, 0.1), (0.25, 0.9), (1.0, 0.4)] }; + let bearing = AngleProfile { points: vec![(0.0, 10.0), (0.6, 80.0), (1.0, 170.0)] }; + s.edit("set", |d, _| { + for id in [open, closed] { + let st = d.node_mut(id).unwrap().appearance.stroke_mut().unwrap(); + st.profile = Some(profile.clone()); + st.pressure = Some(pressure.clone()); + st.tilt = Some(pressure.clone()); + st.bearing = Some(bearing.clone()); + st.rotation = Some(bearing.clone()); + } + Ok(()) + }) + .unwrap(); + // (anchor point, its fraction of the path's length) for each anchor. + let along = |s: &Session, id: NodeId| { + let sp = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); + let pos = sp.anchor_arc_positions(); + let total = *pos.last().unwrap(); + sp.anchors.iter().zip(pos).map(|(a, at)| (a.p, at / total)).collect::<Vec<_>>() + }; + let before = [along(&s, open), along(&s, closed)]; + run(&mut s, "select.set", json!({"ids": [open.0, closed.0]})); + assert_eq!(run(&mut s, "path.reverse", json!({}))["changed"], 2); + for ((id, before), closed) in [open, closed].into_iter().zip(before).zip([false, true]) { + let after = along(&s, id); + let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone(); + // Each anchor keeps the widths it had (its sides swapped, as the path runs the other way) + // and what the pen recorded there. (A closed path's start is where its profile steps.) A + // closed path now starts part way along a span of the width profile, which is resampled + // there, so its smooth curve moves a little. + let near = if closed { 0.02 } else { 1e-6 }; + for (p, t) in before.iter().filter(|(_, t)| !closed || *t > 0.0) { + let u = after.iter().find(|(q, _)| q.distance(*p) < 1e-9).unwrap().1; + let (l, r) = profile.at(*t); + let (l2, r2) = st.profile.as_ref().unwrap().at(u); + assert!((l - r2).abs() < near && (r - l2).abs() < near, "{id:?} at {p:?}: {:?} then {:?}", (l, r), (l2, r2)); + for got in [&st.pressure, &st.tilt] { + assert!((pressure.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} pressure at {p:?}"); + } + for got in [&st.bearing, &st.rotation] { + assert!((bearing.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} bearing at {p:?}"); + } + } + } +} + #[test] fn attributes_survive_a_native_round_trip() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..ab357c50d6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5000,7 +5000,8 @@ The Attributes panel (`window.panel {panel: "attributes"}`, Cmd+F11) reads `attr objects differ) and `recentUrls`. `attributes.set {overprintFill?, overprintStroke?, showCenter?, imageMap?, url?, note?, ids?}` sets them in one undo step; `path.setFillRule {rule: "nonZero"|"evenOdd"}` sets the fill rule of the paths and compound paths, and `path.reverse {reversed?}` makes subpaths run counter-clockwise (true) or clockwise -(false). SVG export wraps an object with a URL in `<a xlink:href>`, and SVG import reads `<a href>` links back. +(false). When every subpath of a path turns alike, its strokes' width profiles (left and right swapped) and pen +pressure, tilt, bearing and rotation turn with it, so the strokes draw as before. SVG export wraps an object with a URL in `<a xlink:href>`, and SVG import reads `<a href>` links back. ```json {"name":"run_command","arguments":{"command":"attributes.set","params":{"ids":[12],"url":"https://example.com","imageMap":"rectangle"}}} From 2d70c6b0ae41c27ad8280ad6535f7ca886fdafc9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 21:06:48 -0800 Subject: [PATCH 21/89] Learn: the tabs, skill and tutorial rows and Back run commands The For you and Skills tab buttons, a skill's row, a tutorial's row and Back changed the Learn panel's view directly, so the control channel and MCP could not choose a tab, open a skill's tutorials or go back. - learn.tab {tab: forYou|skills} shows the panel on that tab; the Skills tab keeps the skill and tutorial it had open. - learn.skill {skill} shows the panel on the Skills tab with that skill's tutorials and returns the skill as learn.list lists it. - learn.back {} goes up one level, as Back does. - A tutorial's row runs learn.show. - The commands are listed in both UI command catalogs, and their labels have a row in every catalog. - Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md. --- crates/mcp/src/ui_catalog.rs | 18 ++++ crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/menus/ui_commands.rs | 18 ++++ crates/ui-egui/src/panels/learn.rs | 129 ++++++++++++++++++----- crates/ui-egui/src/panels/learn/tests.rs | 71 +++++++++++++ docs/mcp.md | 14 ++- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 34 files changed, 306 insertions(+), 29 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e3b57166b7..1be67d191a 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -668,18 +668,36 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", ), + ( + "learn.tab", + "Show Learn Tab", + "", + "{tab: forYou|skills} show the Learn panel on its For you or Skills tab, as the tab buttons do; the Skills tab keeps the skill and tutorial it had open; a missing, non-string or unknown tab is an error and changes nothing; returns {tab, skill, tutorial}, with null for no skill or no tutorial open", + ), ( "learn.skills", "Back to Skills", "", "{} show the Learn panel on its Skills tab with the list of skills, with no skill and no tutorial open, as Back to skills after a skill's last tutorial and the Quick skill guide card do; returns what learn.list returns", ), + ( + "learn.skill", + "Show Skill", + "", + "{skill: id (see learn.list)} show the Learn panel on its Skills tab with that skill's tutorials, as choosing the skill's row does; a missing, non-string or unknown id is an error and changes nothing; returns the skill as learn.list lists it: {id, title, tutorials: [{id, title, steps}]}", + ), ( "learn.show", "Show Tutorial", "", "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", ), + ( + "learn.back", + "Back One Level", + "", + "{} show the Learn panel and go up one level on its Skills tab, as its Back button does: from a tutorial's steps to its skill's tutorials, and from a skill's tutorials to the list of skills; on the list of skills or the For you tab the view stays as it is; returns {tab, skill, tutorial}, as learn.tab does", + ), ( "learn.locateTool", "Show Tool Location", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f469c69c9..5842cecd62 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6854,6 +6854,9 @@ breakText Glyphs Глифи Open Tutorial Sample Отваряне на примерния файл на урока Finish Tutorial Завършване на урок Next Tutorial Следващ урок + Show Learn Tab Показване на раздел в Обучение + Show Skill Показване на умение + Back One Level Едно ниво назад Back to Skills Обратно към уменията @msg tutorial `{id}` has no practice sample урокът `{id}` няма примерен файл за упражнение @msg open the practice sample of `{id}` first (see learn.openSample) първо отворете примерния файл за упражнение на `{id}` (вижте learn.openSample) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a23133933a..1c92ce132a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6907,6 +6907,9 @@ shape Open Otvoren Open Tutorial Sample Otvori uzorak tutorijala Finish Tutorial Završi tutorijal Next Tutorial Sljedeći tutorijal + Show Learn Tab Prikaži karticu u Učenju + Show Skill Prikaži vještinu + Back One Level Jedan nivo nazad Back to Skills Nazad na vještine @msg tutorial `{id}` has no practice sample tutorijal `{id}` nema uzorak za vježbu @msg open the practice sample of `{id}` first (see learn.openSample) prvo otvorite uzorak za vježbu tutorijala `{id}` (vidi learn.openSample) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 373eb0b79d..ac9c01444c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7439,6 +7439,9 @@ breakText Glyphs Glifs Open Tutorial Sample Obre la mostra del tutorial Finish Tutorial Acaba el tutorial Next Tutorial Tutorial següent + Show Learn Tab Mostra una pestanya d'Aprèn + Show Skill Mostra l'habilitat + Back One Level Torna un nivell enrere Back to Skills Torna a les habilitats @msg tutorial `{id}` has no practice sample el tutorial `{id}` no té cap mostra de pràctica @msg open the practice sample of `{id}` first (see learn.openSample) obriu primer la mostra de pràctica de `{id}` (vegeu learn.openSample) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7178be3143..e7d8d0dc69 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7458,6 +7458,9 @@ breakText Glyphs Glyfy Open Tutorial Sample Otevřít ukázku výukového kurzu Finish Tutorial Dokončit výukový kurz Next Tutorial Další výukový kurz + Show Learn Tab Zobrazit kartu Výuky + Show Skill Zobrazit dovednost + Back One Level O úroveň zpět Back to Skills Zpět na dovednosti @msg tutorial `{id}` has no practice sample výukový kurz `{id}` nemá cvičnou ukázku @msg open the practice sample of `{id}` first (see learn.openSample) nejprve otevřete cvičnou ukázku kurzu `{id}` (viz learn.openSample) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 433c6af3d6..a81f22cf25 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6869,6 +6869,9 @@ breakText Glyphs Glyffer Open Tutorial Sample Åbn eksempel til vejledning Finish Tutorial Afslut vejledning Next Tutorial Næste vejledning + Show Learn Tab Vis fane i Lær + Show Skill Vis færdighed + Back One Level Ét niveau tilbage Back to Skills Tilbage til færdigheder @msg tutorial `{id}` has no practice sample vejledningen `{id}` har intet øvelseseksempel @msg open the practice sample of `{id}` first (see learn.openSample) åbn først øvelseseksemplet til `{id}` (se learn.openSample) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 951f6f9c37..9dd9430485 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -7054,6 +7054,9 @@ breakText Glyphs Glyphen Open Tutorial Sample Übungsdatei des Lernprogramms öffnen Finish Tutorial Lernprogramm abschließen Next Tutorial Nächstes Lernprogramm + Show Learn Tab Registerkarte in Lernen anzeigen + Show Skill Fertigkeit anzeigen + Back One Level Eine Ebene zurück Back to Skills Zurück zu den Fertigkeiten @msg tutorial `{id}` has no practice sample das Lernprogramm `{id}` hat keine Übungsdatei @msg open the practice sample of `{id}` first (see learn.openSample) öffnen Sie zuerst die Übungsdatei von `{id}` (siehe learn.openSample) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 62e3adc491..6fc02b5d88 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7411,6 +7411,9 @@ breakText Glyphs Γλύφοι Open Tutorial Sample Άνοιγμα δείγματος οδηγού εκμάθησης Finish Tutorial Ολοκλήρωση οδηγού εκμάθησης Next Tutorial Επόμενος οδηγός εκμάθησης + Show Learn Tab Εμφάνιση καρτέλας της Εκμάθησης + Show Skill Εμφάνιση δεξιότητας + Back One Level Ένα επίπεδο πίσω Back to Skills Πίσω στις δεξιότητες @msg tutorial `{id}` has no practice sample ο οδηγός εκμάθησης `{id}` δεν έχει δείγμα εξάσκησης @msg open the practice sample of `{id}` first (see learn.openSample) ανοίξτε πρώτα το δείγμα εξάσκησης του `{id}` (δείτε learn.openSample) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d1c6aa53af..7502e7cf29 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7402,6 +7402,9 @@ breakText Glyphs Glifos Open Tutorial Sample Abrir muestra del tutorial Finish Tutorial Terminar tutorial Next Tutorial Tutorial siguiente + Show Learn Tab Mostrar pestaña de Aprender + Show Skill Mostrar habilidad + Back One Level Volver un nivel Back to Skills Volver a las habilidades @msg tutorial `{id}` has no practice sample el tutorial `{id}` no tiene muestra de práctica @msg open the practice sample of `{id}` first (see learn.openSample) abre primero la muestra de práctica de `{id}` (consulta learn.openSample) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 45e47fd355..dc01161446 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7493,6 +7493,9 @@ breakText Glyphs Merkit Open Tutorial Sample Avaa opetusohjelman harjoitustiedosto Finish Tutorial Viimeistele opetusohjelma Next Tutorial Seuraava opetusohjelma + Show Learn Tab Näytä Opi-paneelin välilehti + Show Skill Näytä taito + Back One Level Taso takaisin Back to Skills Takaisin taitoihin @msg tutorial `{id}` has no practice sample opetusohjelmalla `{id}` ei ole harjoitustiedostoa @msg open the practice sample of `{id}` first (see learn.openSample) avaa ensin opetusohjelman `{id}` harjoitustiedosto (ks. learn.openSample) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 057dce6872..f39838414b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7453,6 +7453,9 @@ breakText Glyphs Glyphes Open Tutorial Sample Ouvrir le fichier d’exercice du tutoriel Finish Tutorial Terminer le tutoriel Next Tutorial Tutoriel suivant + Show Learn Tab Afficher un onglet d'Apprendre + Show Skill Afficher la compétence + Back One Level Revenir d'un niveau Back to Skills Retour aux compétences @msg tutorial `{id}` has no practice sample le tutoriel `{id}` n’a pas de fichier d’exercice @msg open the practice sample of `{id}` first (see learn.openSample) ouvrez d’abord le fichier d’exercice de `{id}` (voir learn.openSample) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9d056e085a..060007e602 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8849,6 +8849,9 @@ theme Appearance अपीयरेंस Open Tutorial Sample ट्यूटोरियल का नमूना खोलें Finish Tutorial ट्यूटोरियल पूरा करें Next Tutorial अगला ट्यूटोरियल + Show Learn Tab सीखें का टैब दिखाएँ + Show Skill कौशल दिखाएँ + Back One Level एक स्तर वापस Back to Skills कौशल पर वापस जाएँ @msg tutorial `{id}` has no practice sample ट्यूटोरियल `{id}` का कोई अभ्यास नमूना नहीं है @msg open the practice sample of `{id}` first (see learn.openSample) पहले `{id}` का अभ्यास नमूना खोलें (learn.openSample देखें) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c2f1a52cd9..b5dcbc4fa0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7442,6 +7442,9 @@ breakText Glyphs Glifák Open Tutorial Sample Oktatóanyag mintafájljának megnyitása Finish Tutorial Oktatóanyag befejezése Next Tutorial Következő oktatóanyag + Show Learn Tab Tanulás lap megjelenítése + Show Skill Készség megjelenítése + Back One Level Egy szinttel vissza Back to Skills Vissza a készségekhez @msg tutorial `{id}` has no practice sample nincs gyakorló mintafájl ehhez az oktatóanyaghoz: `{id}` @msg open the practice sample of `{id}` first (see learn.openSample) előbb nyissa meg a gyakorló mintafájlt ehhez: `{id}` (lásd: learn.openSample) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a36c629332..8733e7674b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6869,6 +6869,9 @@ breakText Glyphs Glif Open Tutorial Sample Buka contoh tutorial Finish Tutorial Selesaikan tutorial Next Tutorial Tutorial berikutnya + Show Learn Tab Tampilkan tab Belajar + Show Skill Tampilkan keterampilan + Back One Level Kembali satu tingkat Back to Skills Kembali ke keterampilan @msg tutorial `{id}` has no practice sample tutorial `{id}` tidak memiliki contoh latihan @msg open the practice sample of `{id}` first (see learn.openSample) buka contoh latihan `{id}` terlebih dahulu (lihat learn.openSample) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0ac2816b07..48eddf66d6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -7088,6 +7088,9 @@ breakText Glyphs Glifi Open Tutorial Sample Apri l’esempio del tutorial Finish Tutorial Termina il tutorial Next Tutorial Tutorial successivo + Show Learn Tab Mostra una scheda di Impara + Show Skill Mostra competenza + Back One Level Indietro di un livello Back to Skills Torna alle competenze @msg tutorial `{id}` has no practice sample il tutorial `{id}` non ha un esempio di esercitazione @msg open the practice sample of `{id}` first (see learn.openSample) apri prima l’esempio di esercitazione di `{id}` (vedi learn.openSample) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 71de1ce91c..61e1b4d8d7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7154,6 +7154,9 @@ breakText Glyphs 字形 Open Tutorial Sample チュートリアルのサンプルを開く Finish Tutorial チュートリアルを完了 Next Tutorial 次のチュートリアル + Show Learn Tab 学ぶのタブを表示 + Show Skill スキルを表示 + Back One Level 1 つ上の階層に戻る Back to Skills スキルに戻る @msg tutorial `{id}` has no practice sample チュートリアル `{id}` には練習用サンプルがありません @msg open the practice sample of `{id}` first (see learn.openSample) 先に `{id}` の練習用サンプルを開いてください (learn.openSample を参照) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2cff51d94b..738dbcb6bf 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7572,6 +7572,9 @@ font-script Japanese იაპონური Open Tutorial Sample გაკვეთილის ნიმუშის გახსნა Finish Tutorial გაკვეთილის დასრულება Next Tutorial შემდეგი გაკვეთილი + Show Learn Tab სწავლის ჩანართის ჩვენება + Show Skill უნარის ჩვენება + Back One Level ერთი დონით უკან Back to Skills უნარებზე დაბრუნება @msg tutorial `{id}` has no practice sample გაკვეთილს `{id}` არ აქვს სავარჯიშო ნიმუში @msg open the practice sample of `{id}` first (see learn.openSample) ჯერ გახსენით `{id}` გაკვეთილის სავარჯიშო ნიმუში (იხ. learn.openSample) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4715a413f4..ec19a6b246 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7049,6 +7049,9 @@ breakText Glyphs 글리프 Open Tutorial Sample 튜토리얼 샘플 열기 Finish Tutorial 튜토리얼 완료 Next Tutorial 다음 튜토리얼 + Show Learn Tab 학습 탭 표시 + Show Skill 기술 표시 + Back One Level 한 단계 뒤로 Back to Skills 기술로 돌아가기 @msg tutorial `{id}` has no practice sample 튜토리얼 `{id}`에 연습용 샘플이 없습니다 @msg open the practice sample of `{id}` first (see learn.openSample) 먼저 `{id}`의 연습용 샘플을 여세요(learn.openSample 참조) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 94957fb782..b70ec686a4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -7491,6 +7491,9 @@ breakText Glyphs Glyphs Open Tutorial Sample Oefenbestand van tutorial openen Finish Tutorial Tutorial voltooien Next Tutorial Volgende tutorial + Show Learn Tab Tabblad van Leren tonen + Show Skill Vaardigheid tonen + Back One Level Eén niveau terug Back to Skills Terug naar vaardigheden @msg tutorial `{id}` has no practice sample tutorial `{id}` heeft geen oefenbestand @msg open the practice sample of `{id}` first (see learn.openSample) open eerst het oefenbestand van `{id}` (zie learn.openSample) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 48f2671c2d..66a17f4702 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -7315,6 +7315,9 @@ breakText Glyphs Glify Open Tutorial Sample Otwórz przykład samouczka Finish Tutorial Zakończ samouczek Next Tutorial Następny samouczek + Show Learn Tab Pokaż kartę panelu Nauka + Show Skill Pokaż umiejętność + Back One Level Wróć o jeden poziom Back to Skills Powrót do umiejętności @msg tutorial `{id}` has no practice sample samouczek `{id}` nie ma przykładu do ćwiczeń @msg open the practice sample of `{id}` first (see learn.openSample) najpierw otwórz przykład do ćwiczeń samouczka `{id}` (patrz learn.openSample) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f6e54552d4..34b8ed6883 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -7531,6 +7531,9 @@ swatch Yellow Green Verde-amarelado Open Tutorial Sample Abrir Amostra do Tutorial Finish Tutorial Concluir Tutorial Next Tutorial Próximo Tutorial + Show Learn Tab Mostrar Guia de Aprender + Show Skill Mostrar Habilidade + Back One Level Voltar um Nível Back to Skills Voltar às Habilidades @msg tutorial `{id}` has no practice sample o tutorial `{id}` não tem amostra de prática @msg open the practice sample of `{id}` first (see learn.openSample) abra primeiro a amostra de prática de `{id}` (veja learn.openSample) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8dc8336089..86b324b142 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7547,6 +7547,9 @@ shape Open Открытый Open Tutorial Sample Открыть пример урока Finish Tutorial Завершить урок Next Tutorial Следующий урок + Show Learn Tab Показать вкладку панели «Обучение» + Show Skill Показать навык + Back One Level На уровень назад Back to Skills Назад к навыкам @msg tutorial `{id}` has no practice sample у урока `{id}` нет учебного примера @msg open the practice sample of `{id}` first (see learn.openSample) сначала откройте учебный пример урока `{id}` (см. learn.openSample) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cfb9d9734b..cc7bffb639 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8896,6 +8896,9 @@ digits Hindi Hindi Open Tutorial Sample Öppna övningsexempel för självstudie Finish Tutorial Slutför självstudie Next Tutorial Nästa självstudie + Show Learn Tab Visa flik i Lär dig + Show Skill Visa färdighet + Back One Level En nivå tillbaka Back to Skills Tillbaka till färdigheter @msg tutorial `{id}` has no practice sample självstudien `{id}` har inget övningsexempel @msg open the practice sample of `{id}` first (see learn.openSample) öppna först övningsexemplet för `{id}` (se learn.openSample) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2b3e843ba3..b216c2a995 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7414,6 +7414,9 @@ breakText Glyphs Glifler Open Tutorial Sample Öğretici Örneğini Aç Finish Tutorial Öğreticiyi Bitir Next Tutorial Sonraki Öğretici + Show Learn Tab Öğren Sekmesini Göster + Show Skill Beceriyi Göster + Back One Level Bir Düzey Geri Back to Skills Becerilere Dön @msg tutorial `{id}` has no practice sample `{id}` öğreticisinin alıştırma örneği yok @msg open the practice sample of `{id}` first (see learn.openSample) önce `{id}` öğreticisinin alıştırma örneğini açın (bkz. learn.openSample) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50690fa0c7..cd72e5a83f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -7068,6 +7068,9 @@ breakText Glyphs Гліфи Open Tutorial Sample Відкрити приклад уроку Finish Tutorial Завершити урок Next Tutorial Наступний урок + Show Learn Tab Показати вкладку панелі «Навчання» + Show Skill Показати навичку + Back One Level На рівень назад Back to Skills Назад до навичок @msg tutorial `{id}` has no practice sample урок `{id}` не має навчального прикладу @msg open the practice sample of `{id}` first (see learn.openSample) спочатку відкрийте навчальний приклад уроку `{id}` (див. learn.openSample) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d8469bce53..b7eb074068 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7069,6 +7069,9 @@ breakText Glyphs Glyph Open Tutorial Sample Mở tệp mẫu của hướng dẫn Finish Tutorial Hoàn tất hướng dẫn Next Tutorial Hướng dẫn tiếp theo + Show Learn Tab Hiện thẻ của Học + Show Skill Hiện kỹ năng + Back One Level Quay lại một cấp Back to Skills Quay lại kỹ năng @msg tutorial `{id}` has no practice sample hướng dẫn `{id}` không có tệp mẫu thực hành @msg open the practice sample of `{id}` first (see learn.openSample) hãy mở tệp mẫu thực hành của `{id}` trước (xem learn.openSample) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9d9110276..1d7f4ead53 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -7297,6 +7297,9 @@ breakText Glyphs 字形 Open Tutorial Sample 打开教程示例 Finish Tutorial 完成教程 Next Tutorial 下一个教程 + Show Learn Tab 显示学习选项卡 + Show Skill 显示技能 + Back One Level 返回上一级 Back to Skills 返回技能 @msg tutorial `{id}` has no practice sample 教程 `{id}` 没有练习示例 @msg open the practice sample of `{id}` first (see learn.openSample) 请先打开 `{id}` 的练习示例 (请参阅 learn.openSample) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a0d987fe69..213c80600e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7104,6 +7104,9 @@ breakText Glyphs 字符 Open Tutorial Sample 開啟教學課程範例 Finish Tutorial 完成教學課程 Next Tutorial 下一個教學課程 + Show Learn Tab 顯示學習索引標籤 + Show Skill 顯示技能 + Back One Level 返回上一層 Back to Skills 返回技能 @msg tutorial `{id}` has no practice sample 教學課程 `{id}` 沒有練習範例 @msg open the practice sample of `{id}` first (see learn.openSample) 請先開啟 `{id}` 的練習範例 (請參閱 learn.openSample) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index cd311d45a8..af474fb4f7 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -669,18 +669,36 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", ), + ( + "learn.tab", + "Show Learn Tab", + "", + "{tab: forYou|skills} show the Learn panel on its For you or Skills tab, as the tab buttons do; the Skills tab keeps the skill and tutorial it had open; a missing, non-string or unknown tab is an error and changes nothing; returns {tab, skill, tutorial}, with null for no skill or no tutorial open", + ), ( "learn.skills", "Back to Skills", "", "{} show the Learn panel on its Skills tab with the list of skills, with no skill and no tutorial open, as Back to skills after a skill's last tutorial and the Quick skill guide card do; returns what learn.list returns", ), + ( + "learn.skill", + "Show Skill", + "", + "{skill: id (see learn.list)} show the Learn panel on its Skills tab with that skill's tutorials, as choosing the skill's row does; a missing, non-string or unknown id is an error and changes nothing; returns the skill as learn.list lists it: {id, title, tutorials: [{id, title, steps}]}", + ), ( "learn.show", "Show Tutorial", "", "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", ), + ( + "learn.back", + "Back One Level", + "", + "{} show the Learn panel and go up one level on its Skills tab, as its Back button does: from a tutorial's steps to its skill's tutorials, and from a skill's tutorials to the list of skills; on the list of skills or the For you tab the view stays as it is; returns {tab, skill, tutorial}, as learn.tab does", + ), ( "learn.locateTool", "Show Tool Location", diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index ee7967c8a3..ff61011fe4 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -5,8 +5,9 @@ //! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool //! is ([`locate`]). Back goes up one level. Under the steps, View more opens the tutorial's practice //! sample as a new untitled document; Finish tutorial then marks it finished, and Next Up shows the -//! next tutorial in the same skill. `learn.list` lists the tutorials, `learn.skills` shows the list -//! of skills, `learn.show` opens one tutorial, `learn.locateTool` shows where a tool is, +//! next tutorial in the same skill. `learn.list` lists the tutorials, `learn.tab` chooses a tab, +//! `learn.skills` shows the list of skills, `learn.skill` opens one skill, `learn.show` opens one +//! tutorial, `learn.back` goes up one level, `learn.locateTool` shows where a tool is, //! `learn.openSample`, `learn.finish` and `learn.next` run the three practice controls, and //! `window.learn` shows or hides the panel. @@ -48,14 +49,23 @@ pub struct Practice { pub finished: Vec<&'static str>, } +/// Each tab with the name `learn.tab` takes for it. +const TABS: [(Tab, &str); 2] = [(Tab::ForYou, "forYou"), (Tab::Skills, "skills")]; + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let mut chosen = None; ui.horizontal(|ui| { - for (tab, label) in [(Tab::ForYou, tl!("For you")), (Tab::Skills, tl!("Skills"))] { + for ((tab, name), label) in TABS.into_iter().zip([tl!("For you"), tl!("Skills")]) { if ui.selectable_label(app.ui.learn.tab == tab, label).clicked() { - app.ui.learn.tab = tab; + chosen = Some(name); } } }); + if let Some(name) = chosen + && let Err(e) = app.run("learn.tab", json!({ "tab": name })) + { + app.status(e); + } crate::widgets::divider(ui); match app.ui.learn.tab { Tab::ForYou => for_you(app, ui), @@ -97,36 +107,40 @@ fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) { /// The Skills tab: the skills, a skill's tutorials, or a tutorial's steps. fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { + let mut run = None; let open = app.ui.learn.tutorial.and_then(catalog::tutorial); - if let Some((skill, tutorial)) = open { + if let Some((_, tutorial)) = open { if back(ui) { - app.ui.learn.skill = Some(skill.id); - app.ui.learn.tutorial = None; + run = Some(("learn.back", json!({}))); } steps(app, ui, tutorial); practice(app, ui, tutorial); - return; - } - if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { + } else if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { if back(ui) { - app.ui.learn.skill = None; + run = Some(("learn.back", json!({}))); } crate::widgets::subheader(ui, skill.title); for tutorial in skill.tutorials { if row(ui, tutorial.title, None).clicked() { - app.ui.learn.tutorial = Some(tutorial.id); + run = Some(("learn.show", json!({ "tutorial": tutorial.id }))); } } - return; - } - for skill in catalog::SKILLS { - if row(ui, skill.title, Some(skill.tutorials.len())).clicked() { - app.ui.learn.skill = Some(skill.id); + } else { + for skill in catalog::SKILLS { + if row(ui, skill.title, Some(skill.tutorials.len())).clicked() { + run = Some(("learn.skill", json!({ "skill": skill.id }))); + } } } + if let Some((cmd, params)) = run + && let Err(e) = app.run(cmd, params) + { + app.status(e); + } } -/// The Back button above a skill's tutorials and a tutorial's steps; true when it was clicked. +/// The Back button above a skill's tutorials and a tutorial's steps (`learn.back`); true when it +/// was clicked. fn back(ui: &mut Ui) -> bool { crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() } @@ -251,14 +265,18 @@ fn row(ui: &mut Ui, title: &str, tutorials: Option<usize>) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } -/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.skills`, `learn.show`, -/// `learn.locateTool`, `learn.openSample`, `learn.finish` and `learn.next`. +/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.tab`, `learn.skills`, +/// `learn.skill`, `learn.show`, `learn.back`, `learn.locateTool`, `learn.openSample`, `learn.finish` +/// and `learn.next`. pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> { Some(match id { "window.learn" => toggle(app, p), "learn.list" => Ok(list()), + "learn.tab" => tab(app, p), "learn.skills" => skills_list(app), + "learn.skill" => open_skill(app, p), "learn.show" => open(app, p), + "learn.back" => back_up(app), "learn.locateTool" => locate::locate_tool(app, p), "learn.openSample" => open_sample(app, p), "learn.finish" => finish(app, p), @@ -289,11 +307,72 @@ fn toggle(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { /// `learn.list`: every skill with its tutorials and their step counts. fn list() -> Value { - let skills = catalog::SKILLS.iter().map(|s| { - let tutorials: Vec<Value> = s.tutorials.iter().map(|t| json!({"id": t.id, "title": t.title, "steps": t.steps.len()})).collect(); - json!({"id": s.id, "title": s.title, "tutorials": tutorials}) - }); - json!({ "skills": skills.collect::<Vec<_>>() }) + json!({ "skills": catalog::SKILLS.iter().map(skill_json).collect::<Vec<_>>() }) +} + +/// A skill as `learn.list` lists it: `{id, title, tutorials: [{id, title, steps}]}`. +fn skill_json(s: &catalog::Skill) -> Value { + let tutorials: Vec<Value> = s.tutorials.iter().map(|t| json!({"id": t.id, "title": t.title, "steps": t.steps.len()})).collect(); + json!({"id": s.id, "title": s.title, "tutorials": tutorials}) +} + +/// What the panel shows: `{tab: forYou|skills, skill, tutorial}`, with null for no skill or no +/// tutorial open. +fn view_json(view: &View) -> Value { + let tab = TABS.iter().find(|(t, _)| *t == view.tab).map(|(_, name)| *name); + json!({"tab": tab, "skill": view.skill, "tutorial": view.tutorial}) +} + +/// `learn.tab {tab}`: the For you and Skills tabs. Show the panel on that tab (`forYou` or +/// `skills`); the Skills tab keeps the skill and tutorial it had open → the view ([`view_json`]). A +/// missing or unknown tab is an error and changes nothing. +fn tab(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { + let key = "tab"; + let name = match p.get(key) { + None | Some(Value::Null) => return Err(format!("missing `{key}`")), + Some(Value::String(name)) => name.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let names = || TABS.map(|(_, name)| name).join(", "); + let tab = TABS.iter().find(|(_, n)| *n == name).map(|(t, _)| *t).ok_or_else(|| format!("`{key}` must be one of {}", names()))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn.tab = tab; + Ok(view_json(&app.ui.learn)) +} + +/// `learn.skill {skill}`: a skill's row on the Skills tab. Show the panel on the Skills tab with +/// that skill's tutorials → the skill, as `learn.list` lists it. A missing or unknown id is an error +/// and changes nothing. +fn open_skill(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { + let key = "skill"; + let id = match p.get(key) { + None | Some(Value::Null) => return Err(format!("missing `{key}`")), + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let ids = || catalog::SKILLS.iter().map(|s| s.id).collect::<Vec<_>>().join(", "); + let skill = catalog::skill(id).ok_or_else(|| format!("`{key}` must be one of {}", ids()))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }; + Ok(skill_json(skill)) +} + +/// `learn.back`: the Back button on the Skills tab. Show the panel and go up one level: from a +/// tutorial's steps to its skill's tutorials, and from a skill's tutorials to the list of skills → +/// the view ([`view_json`]). On the list of skills or the For you tab the view stays as it is. +fn back_up(app: &mut VectorcraftApp) -> Result<Value, String> { + crate::panel_docking::reveal_panel(app, ID)?; + let view = &mut app.ui.learn; + if view.tab == Tab::Skills { + if let Some((skill, _)) = view.tutorial.and_then(catalog::tutorial) { + view.skill = Some(skill.id); + view.tutorial = None; + } else if view.skill.is_some() { + view.skill = None; + view.tutorial = None; + } + } + Ok(view_json(view)) } /// `learn.skills`: Back to skills, and the Quick skill guide card. Show the panel on the Skills tab diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index 4bd00648d1..c49305920f 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -493,6 +493,77 @@ fn learn_show_rejects_a_missing_or_unknown_tutorial() { } } +/// The tab buttons, a skill's row, a tutorial's row and Back run `learn.tab`, `learn.skill`, +/// `learn.show` and `learn.back`: each one also shows a panel that was hidden, as the command does. +#[test] +fn the_tabs_rows_and_back_run_their_commands() { + for cmd in ["learn.tab", "learn.skill", "learn.back"] { + assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == cmd), "{cmd} is in the UI command catalog"); + } + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("gradient").unwrap(); + let press = |app: &mut VectorcraftApp, name: &str, view: View| { + app.run("window.learn", json!({"show": false})).unwrap(); + assert!(!shown(app)); + click(app, &ctx, name, show); + assert_eq!(app.ui.learn, view, "{name}"); + assert!(shown(app), "{name} runs its command: {}", app.ui.status); + }; + press(&mut app, "Skills", View { tab: Tab::Skills, skill: None, tutorial: None }); + press(&mut app, skill.title, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); + press(&mut app, tutorial.title, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }); + press(&mut app, "For you", View { tab: Tab::ForYou, skill: Some(skill.id), tutorial: Some(tutorial.id) }); + press(&mut app, "Skills", View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }); + press(&mut app, "Back", View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); + press(&mut app, "Back", View { tab: Tab::Skills, skill: None, tutorial: None }); +} + +/// `learn.tab` switches tabs and keeps what the Skills tab had open; `learn.skill` opens a skill's +/// tutorials and answers the skill as `learn.list` lists it; `learn.back` goes up one level and +/// keeps the view on the list of skills and on the For you tab. +#[test] +fn learn_tab_skill_and_back_move_the_view() { + let mut app = app(); + let list = app.run("learn.list", json!({})).unwrap(); + assert_eq!(app.run("learn.skill", json!({"skill": "color"})).unwrap(), list["skills"][2]); + assert!(shown(&app)); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: None }); + app.run("learn.show", json!({"tutorial": "gradient"})).unwrap(); + let view = json!({"tab": "forYou", "skill": "color", "tutorial": "gradient"}); + assert_eq!(app.run("learn.tab", json!({"tab": "forYou"})).unwrap(), view); + assert_eq!(app.run("learn.back", json!({})).unwrap(), view, "Back on the For you tab keeps the view"); + assert_eq!(app.run("learn.tab", json!({"tab": "skills"})).unwrap(), json!({"tab": "skills", "skill": "color", "tutorial": "gradient"})); + assert_eq!(app.run("learn.back", json!({})).unwrap(), json!({"tab": "skills", "skill": "color", "tutorial": null})); + let skills = json!({"tab": "skills", "skill": null, "tutorial": null}); + assert_eq!(app.run("learn.back", json!({})).unwrap(), skills); + assert_eq!(app.run("learn.back", json!({})).unwrap(), skills, "Back on the list of skills keeps the view"); + app.run("window.learn", json!({"show": false})).unwrap(); + assert_eq!(app.run("learn.back", json!({})).unwrap(), skills); + assert!(shown(&app), "learn.back shows the panel"); +} + +/// `learn.tab` and `learn.skill` reject a missing, mistyped or unknown value and change nothing. +#[test] +fn learn_tab_and_learn_skill_reject_a_missing_or_unknown_value() { + let mut app = app(); + let before = app.ui.learn.clone(); + let skills = catalog::SKILLS.iter().map(|s| s.id).collect::<Vec<_>>().join(", "); + for (cmd, params, error) in [ + ("learn.tab", json!({}), "missing `tab`".to_string()), + ("learn.tab", json!({"tab": null}), "missing `tab`".to_string()), + ("learn.tab", json!({"tab": 1}), "`tab` must be a string".to_string()), + ("learn.tab", json!({"tab": "Skills"}), "`tab` must be one of forYou, skills".to_string()), + ("learn.skill", json!({}), "missing `skill`".to_string()), + ("learn.skill", json!({"skill": ["draw"]}), "`skill` must be a string".to_string()), + ("learn.skill", json!({"skill": "nope"}), format!("`skill` must be one of {skills}")), + ] { + assert_eq!(app.run(cmd, params.clone()).unwrap_err(), error, "{cmd} {params}"); + assert_eq!(app.ui.learn, before); + assert!(!shown(&app)); + } +} + /// The Pen tutorial's steps, as the panel shows them. fn pen_paths(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.ui.learn = View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("pen-paths") }; diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..a9e85a153a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2861,8 +2861,15 @@ other value of `show` is an error and changes nothing. `learn.list {}` → `{ski lists them, where `steps` is the number of steps. `learn.skills {}` shows the panel on its Skills tab with the list of skills, with no skill and no tutorial open, as the Quick skill guide card and Back to skills do, and answers what `learn.list` answers. `learn.show {tutorial}` shows the panel on its Skills tab with that -tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error -and changes nothing. +tutorial's steps → `{skill, tutorial, title, steps: [text]}`; choosing a tutorial's row runs it. A missing, non-string +or unknown `tutorial` is an error and changes nothing. The For you and Skills tab buttons run `learn.tab {tab}`, with +`tab` set to `forYou` or `skills`, which shows the panel on that tab → `{tab, skill, tutorial}`, with null for no +skill or no tutorial open; the Skills tab keeps the skill and tutorial it had open. Choosing a skill's row runs +`learn.skill {skill}`, which shows the panel on the Skills tab with that skill's tutorials → the skill as `learn.list` +lists it. A missing, non-string or unknown `tab` or `skill` is an error and changes nothing. Back runs `learn.back {}`, +which shows the panel and goes up one level on the Skills tab, from a tutorial's steps to its skill's tutorials and +from a skill's tutorials to the list of skills, and answers what `learn.tab` answers; on the list of skills or the For +you tab the view stays as it is. Under a tutorial's steps, View more opens the tutorial's practice sample, a small drawing of VectorCraft's own with the art its steps work on, as a new untitled document that becomes the active one; Save asks where to save it. Once the @@ -2886,6 +2893,9 @@ is an error, and an error changes nothing. Behavior reference: {"name":"invoke_menu","arguments":{"command":"learn.finish","params":{"tutorial":"pen-paths"}}} {"name":"invoke_menu","arguments":{"command":"learn.next","params":{"tutorial":"pen-paths"}}} {"name":"invoke_menu","arguments":{"command":"learn.skills","params":{}}} +{"name":"invoke_menu","arguments":{"command":"learn.skill","params":{"skill":"draw"}}} +{"name":"invoke_menu","arguments":{"command":"learn.back","params":{}}} +{"name":"invoke_menu","arguments":{"command":"learn.tab","params":{"tab":"forYou"}}} ``` A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..0f47c6ad5d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -914,7 +914,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Perspective Distort and Free Distort (`object.distort`) turn each warped object's bounding box as the warp turns the object at its own center, the objects inside groups, compound paths and clip groups and the art of linked opacity masks included, so the next Selection or Free Transform drag uses the tilted box; Object › Transform › Reset Bounding Box squares it again, and corners left where they are add no undo step (VectorCraft's own rule; `bboxrotate::distort_turns_an_upright_box_with_the_warp_at_its_center`, `distort_turns_each_member_and_linked_mask_by_its_own_warp`, `mirrored_and_collapsing_distorts_keep_valid_angles`, `distort_angles_undo_redo_and_reopen_together`, `xform::unchanged_distort_records_no_history`). - The Free Transform widget's Constrain works in Free Distort as well as in Free Transform ([the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/free-transform-tool.html)). With Constrain on, or Shift held, a corner dragged in Free Distort moves only along the line through the opposite corner, in the box's own proportions, and the other three corners stay in place; on a turned box the line runs through the box's own corners. The line is VectorCraft's own rule. Cmd/Ctrl+Alt+Shift on a corner still drags in Perspective Distort, where Constrain stays unavailable (tools `xform::free::tests::free_distort_corner_keeps_to_its_diagonal_with_shift_or_constrain`, `shift_pressed_during_a_free_distort_drag_constrains_it`, `a_turned_box_is_constrained_along_its_own_diagonal`, `along_diagonal_of_a_degenerate_box`; engine `xform::free_transform_constrained_distort_keeps_the_corner_on_its_diagonal`; ui-egui `free_transform::tests::the_widget_sets_the_mode_and_a_corner_drag_then_distorts`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). + - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list`, `learn.tab {tab}`, `learn.skill {skill}`, `learn.show {tutorial}` and `learn.back` script it, and the tab buttons, the rows and Back run them ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish`, `learn.next` and `learn.skills` (Back to skills) script them. Not done: the help page's short video, and progress kept between sessions and shown in the skill rings. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `learn_skills_shows_the_list_of_skills`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`. - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62a99726a2..cf470d7a9b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -383,7 +383,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | -| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished, and Next Up shows the next tutorial in the same skill (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.skills`, `learn.show`, `learn.locateTool`, `learn.openSample`, `learn.finish`, `learn.next`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished, and Next Up shows the next tutorial in the same skill (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.tab`, `learn.skills`, `learn.skill`, `learn.show`, `learn.back`, `learn.locateTool`, `learn.openSample`, `learn.finish`, `learn.next`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | From 2d0bb80a52a67f494dee62b4c5d732c8d9aabdce Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 21:14:06 -0800 Subject: [PATCH 22/89] Area type: contours whose crossings round to one x keep the gap between them Two intervals at the middle of a band joined whenever their crossings came out at the same x, even when the two edges are not along one line and only round to that x. A slanted edge whose crossings of the band all round to the x of a separate contour's edge then let a line run across the gap between the contours. The crossings now carry the line they were found along. Intervals join at an x only when both crossings are along one line (contours that meet along an edge) or both lie exactly at that x. --- crates/text/src/layout/region.rs | 71 ++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 34c48942d5..7e9815fbfb 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -206,6 +206,10 @@ type Slant = (Point, Point, (f64, f64), (f64, f64)); /// The line through two points. type Line = (Point, Point); +/// The x at which an edge crosses a height, and the line it was found along (`None` when it is +/// the edge's end). +type Crossing = (f64, Option<Line>); + /// The x of the line through `p` and `q` (not horizontal) at `y`. fn line_x(p: Point, q: Point, y: f64) -> f64 { p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x) @@ -367,20 +371,36 @@ fn band_intervals(polys: &[Vec<Point>], top: f64, bottom: f64) -> Vec<(f64, f64) let (edges, _, mut xs) = band_boundaries(polys, top, bottom); // The edges of contours that meet along an edge cross the middle of the band at the same x, // since each is crossed on the line through the same two points: the intervals on either side - // join there (an edge that bounds the shape there is still subtracted). Intervals with any gap + // join there (an edge that bounds the shape there is still subtracted). Crossings of other edges + // that round to the same x join only when both lie exactly at that x. Intervals with any gap // between them stay apart. - xs.sort_by(f64::total_cmp); - let mut mid: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect(); - mid.dedup_by(|next, prev| { - let seam = next.0 <= prev.1; - if seam { - prev.1 = prev.1.max(next.1); + let y = (top + bottom) * 0.5; + xs.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| line_order(a.1, b.1))); + let mut mid: Vec<(f64, f64)> = Vec::new(); + let mut last: Option<Line> = None; + for &[(s, ks), (t, kt)] in xs.as_chunks::<2>().0 { + match mid.last_mut() { + Some(prev) if s <= prev.1 && ((ks.is_some() && last == ks) || (exactly_at(last, s, y) && exactly_at(ks, s, y))) => prev.1 = prev.1.max(t), + _ => mid.push((s, t)), } - seam - }); + last = kt; + } subtract_intervals(&mid, &edges) } +/// An order of the lines crossings of the band's middle were found along, which keeps crossings +/// along one line next to each other; a crossing at an edge's end (`None`) comes first. +fn line_order(a: Option<Line>, b: Option<Line>) -> std::cmp::Ordering { + let key = |l: Option<Line>| l.map_or([f64::NEG_INFINITY; 4], |(p, q)| [p.x, p.y, q.x, q.y]); + key(a).iter().zip(key(b)).fold(std::cmp::Ordering::Equal, |o, (u, v)| o.then(u.total_cmp(&v))) +} + +/// Does a crossing of the height `y` at `x`, found along `line` (or at an edge's end when `None`), +/// lie exactly at `x`? +fn exactly_at(line: Option<Line>, x: f64, y: f64) -> bool { + line.is_none_or(|(p, q)| on_line_exactly(p, q, Point::new(x, y))) +} + /// The x at which `polys` (even-odd) is inside somewhere in the band `top..bottom`, sorted and /// disjoint: the intervals at the middle of the band and every x at which an edge lies within the /// band. A vertical line at any other x meets no edge inside the band, so it is inside somewhere in @@ -423,17 +443,19 @@ fn band_cover_closed(polys: &[Vec<Point>], top: f64, bottom: f64) -> Vec<(f64, f /// the band, stretches of edges that an even number of edges cover, and vertical edges. The flag is /// false when [`MAX_EDGE_PAIRS`] left edges unpaired, which can only add to the result. Last, the x /// at which the edges cross the middle of the band (even-odd, as [`poly_intervals`] finds them), -/// unsorted, each found along the line [`slant_boundaries`] gives its edge. -fn band_boundaries(polys: &[Vec<Point>], top: f64, bottom: f64) -> (Vec<(f64, f64)>, bool, Vec<f64>) { +/// unsorted, each found along the line [`slant_boundaries`] gives its edge, with that line (`None` +/// for a crossing at an edge's end). +fn band_boundaries(polys: &[Vec<Point>], top: f64, bottom: f64) -> (Vec<(f64, f64)>, bool, Vec<Crossing>) { let (mut flats, mut slants) = band_edges(polys, top, bottom); let (mut edges, paired, lines) = slant_boundaries(&mut slants); let y = (top + bottom) * 0.5; - let mut xs: Vec<f64> = slants.iter().zip(&lines).filter(|((p, q, ..), _)| p.y <= y && y < q.y).map(|(_, &(p, q))| line_x(p, q, y)).collect(); + let mut xs: Vec<Crossing> = + slants.iter().zip(&lines).filter(|((p, q, ..), _)| p.y <= y && y < q.y).map(|(_, &(p, q))| (line_x(p, q, y), Some((p, q)))).collect(); // When the middle is the band's bottom (a band of no height), the edges that start there cross // it but are not in the band: they cross it at their lower end. if y == bottom { let starts = polys.iter().flat_map(|poly| poly.iter().zip(poly.iter().cycle().skip(1))); - xs.extend(starts.filter(|(a, b)| a.y != b.y && a.y.min(b.y) == y).map(|(a, b)| if a.y < b.y { a.x } else { b.x })); + xs.extend(starts.filter(|(a, b)| a.y != b.y && a.y.min(b.y) == y).map(|(a, b)| (if a.y < b.y { a.x } else { b.x }, None))); } // The x covered by an odd number of the horizontal edges at one height: pairs of their // sorted ends. @@ -1004,6 +1026,29 @@ fn shared_slanted_edge_still_joins_near_canvas_limit() { assert_spans(&cells[0].spans(top, bottom), &[(-100.0 + 3.0 * bottom / 7.0, 100.0 + 3.0 * top / 7.0)]); } +/// A slanted edge and a vertical edge of two contours less than one unit in the last place apart +/// near the edge of the canvas, whose crossings of the band's middle round to the same x, are not +/// one edge: the line stops at the gap, drawn either way around. +#[cfg(test)] +#[test] +fn contours_whose_crossings_round_together_stay_apart() { + let x = 3_999_000.0; + let u = 2f64.powi(-31); + assert_eq!((x + u).to_bits(), x.to_bits() + 1); + let left: &[(f64, f64)] = &[(x - 100.0, 0.0), (x, 0.0), (x + u, 10.0), (x - 100.0, 10.0)]; + let right: &[(f64, f64)] = &[(x + u, 0.0), (x + 100.0, 0.0), (x + 100.0, 10.0), (x + u, 10.0)]; + let mirror = |c: &[(f64, f64)]| -> Vec<(f64, f64)> { c.iter().map(|&(px, py)| (px, 10.0 - py)).collect() }; + let (left_m, right_m) = (mirror(left), mirror(right)); + for frame in [closed(&[left, right]), closed(&[&right_m, &left_m])] { + let cells = Region::cells(&frame, &LayoutOptions::default(), &[]); + for (top, bottom) in [(8.0, 9.0), (1.0, 2.0)] { + let spans = cells[0].spans(top, bottom); + assert_eq!(spans.len(), 2, "{top}..{bottom}: {spans:?}"); + assert_spans(&spans, &[(x - 100.0, x + u), (x + u, x + 100.0)]); + } + } +} + /// Area type in two slanted contours four units in the last place apart near the edge of the canvas /// sets no line across the gap between them. #[cfg(test)] From c5e2e9210a7d0ce9a7acd33eeb495ad882c7b30e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 21:16:20 -0800 Subject: [PATCH 23/89] Sort Objects: an opacity sort that gives marks their own opacities changes nothing With apply "opacity", Sort Objects compared each object's opacity with the value it gets as a percentage, within 1e-6. An evenly stepped value can differ from a mark's percentage by more than that while it rounds to the same stored opacity: marks at 30%, 65% and 100% sorted by x with distribute on keep their opacities, yet the 65% mark counted as changed. The sort then made an undo step and replaced the opacity the graph stores for the series (30%) with 65%. The sort now compares each object's stored opacity with the opacity it is given, as stored, both to skip a run that changes nothing and to pick the marks whose opacity the graph keeps. --- crates/engine/src/cmd/addons/arrange.rs | 12 +++++++----- crates/engine/src/cmd/graph/tests.rs | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index d7cae868ed..67cf475979 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -516,16 +516,18 @@ fn sort(s: &mut Session, p: &Value) -> Result<Value> { } let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect(); if apply == "opacity" { - if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) { + // The opacity each object is given, as stored; an object whose stored opacity it already is has not changed. + let given: Vec<(NodeId, f32)> = wanted.iter().map(|(o, v)| (o.id, (v / 100.0) as f32)).collect(); + let changed: Vec<NodeId> = given.iter().filter(|(id, a)| d.node(*id).is_some_and(|n| n.opacity != *a)).map(|(id, _)| *id).collect(); + if changed.is_empty() { return Ok(count); } s.edit("Sort Objects", |d, _| { - for (o, v) in &wanted { - if let Some(n) = d.node_mut(o.id) { - n.opacity = (v / 100.0) as f32; + for (id, a) in &given { + if let Some(n) = d.node_mut(*id) { + n.opacity = *a; } } - let changed: Vec<NodeId> = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect(); keep_series_opacity(d, &changed); Ok(()) })?; diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c997e803d5..0ca3cf1d96 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -757,6 +757,24 @@ fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() { assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6)); } +/// A Sort Objects that gives series marks the opacities they already have changes nothing: the value the graph stores +/// stays and no undo step is made, even when the evenly stepped percentage differs from a mark's in the last digits. +#[test] +fn an_opacity_sort_objects_that_changes_no_mark_keeps_the_stored_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 65})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the columns have 30%, 65% and 100%, the values an even step from 30% to 100% gives them. + let undo = s.doc().unwrap().history.undo.len(); + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap(); + let opacities: Vec<f32> = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.65, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); +} + /// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark /// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them /// when the graph is drawn again. From d64ba43e50cf90e3ab0055b8e8325f85e87864a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 21:17:21 -0800 Subject: [PATCH 24/89] 3D: Inflate with no volume solves each puff only when its solid is taken At a Volume of 0 % no shape puffs, so the eye does not depend on the other shapes. Each shape's membrane is now solved as its solid is taken, and a caller that stops at the object's face limit solves none of the shapes after it. With any volume every puff is still solved first, since the highest one places the eye, and the solids are unchanged bit for bit. The shadow no longer builds the solid of a fully transparent fill or stroke: its puff still places the eye, and at 0 % volume it is not solved either. --- crates/three-d/src/shadow.rs | 18 +++--- crates/three-d/src/shadow/tests.rs | 32 ++++++++++ crates/three-d/src/solid.rs | 4 +- crates/three-d/src/solid/inflate.rs | 93 ++++++++++++++++++++++++----- crates/three-d/src/solid/tests.rs | 63 +++++++++++++++++++ docs/target-app-parity.md | 1 + docs/three-d.md | 4 ++ 7 files changed, 191 insertions(+), 24 deletions(-) diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs index d7cd01a869..9c2139bfc2 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -172,8 +172,8 @@ impl ShadowCaster { /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each /// shape whose entry in `casts` is true: the eye is placed for every shape, as for the solids - /// drawn, but the faces of the others are not kept (their range is empty). A shape that does - /// not inflate is left out, as its solid is; the result says which, in order. + /// drawn, but the solids of the others are not built (their range is empty). A casting shape + /// that does not inflate is left out, as its solid is; the result says which, in order. pub fn inflate( &mut self, shapes: &[(&PathData, FillRule)], @@ -182,11 +182,15 @@ impl ShadowCaster { options: Inflate, view: View, ) -> Vec<Result<Range<usize>, String>> { - inflate_faces(shapes, bounds, options, view, true) - .enumerate() - .map(|(i, r)| { - let (_, faces, projection) = r?; - if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) } + let mut solids = inflate_faces(shapes, bounds, options, view, true); + (0..shapes.len()) + .map(|i| { + if !casts.get(i).copied().unwrap_or(true) { + solids.pass(); + return Ok(self.faces.len()..self.faces.len()); + } + let (_, faces, projection) = solids.next().unwrap_or_else(|| Ok((vec![], vec![], None)))?; + self.add(faces, projection) }) .collect() } diff --git a/crates/three-d/src/shadow/tests.rs b/crates/three-d/src/shadow/tests.rs index 3fb3767177..00aedc3a53 100644 --- a/crates/three-d/src/shadow/tests.rs +++ b/crates/three-d/src/shadow/tests.rs @@ -185,3 +185,35 @@ fn every_effect_casts_a_shadow() { // The surface turns about the square's left edge, reaching 100 to either side of it. assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}"); } + +/// An Inflate shape that casts nothing still places the eye with its puff, but its solid is not +/// built (nor, with no volume, its puff solved); the shapes that cast keep the very faces and +/// projection they have when every shape casts. +#[test] +fn an_inflate_shape_that_casts_nothing_builds_no_solid() { + let (a, b) = (square(100.0, 100.0, 100.0), square(150.0, 150.0, 30.0)); + let shapes = [(&a, FillRule::NonZero), (&b, FillRule::NonZero)]; + let bounds = Rect::new(100.0, 100.0, 200.0, 200.0); + let view = View { perspective: 60.0, rotation_y: 30.0, ..front() }; + let built = || crate::solid::INFLATED.with(std::cell::Cell::get); + let puffed = || crate::solid::PUFFED.with(std::cell::Cell::get); + for o in [Inflate::default(), Inflate { volume: 0.0, depth: 20.0, ..Inflate::default() }] { + let mut all = ShadowCaster::new(); + let every = all.inflate(&shapes, &[true, true], bounds, o, view); + let (start, solved) = (built(), puffed()); + let mut some = ShadowCaster::new(); + let kept = some.inflate(&shapes, &[false, true], bounds, o, view); + assert_eq!(built(), start + 1, "{o:?}: only the casting shape's solid is built"); + if o.volume == 0.0 { + assert_eq!(puffed(), solved + 1, "{o:?}: only the casting shape's puff is solved"); + } + let (Ok(first), Ok(second)) = (&every[0], &every[1]) else { panic!("{every:?}") }; + assert!(!first.is_empty() && !second.is_empty()); + assert_eq!(kept, vec![Ok(0..0), Ok(0..second.len())]); + assert_eq!(format!("{:?}", some.faces), format!("{:?}", &all.faces[second.clone()])); + assert_eq!(some.projection, all.projection); + let mut none = ShadowCaster::new(); + assert_eq!(none.inflate(&shapes, &[false, false], bounds, o, view), vec![Ok(0..0), Ok(0..0)]); + assert!(none.is_empty() && none.projection.is_none()); + } +} diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 49b0738d9e..652f46a3f1 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -27,9 +27,9 @@ pub use build::{extrude, plane}; pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] use inflate::GRID_WORK; -#[cfg(test)] -pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; +#[cfg(test)] +pub(crate) use inflate::{INFLATED, PUFFED, SOLVE_FIRST}; pub use inflate::{inflate, inflate_each, inflate_together}; #[cfg(test)] use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d3773a92f9..2500af48c3 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -23,6 +23,8 @@ struct Puff { /// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> { + #[cfg(test)] + PUFFED.with(|n| n.set(n.get() + 1)); // The sides take one face for each edge of the outline in each slice; the grid's faces are // counted as they are built. let size = bounds.width().max(bounds.height()); @@ -66,7 +68,7 @@ pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: } /// [`inflate_together`], each solid built only as the iterator reaches it: a caller that stops early -/// builds none of the solids after. +/// builds none of the solids after (and with no volume, solves none of their puffs). pub fn inflate_each( shapes: &[(&PathData, FillRule)], bounds: Rect, @@ -77,28 +79,89 @@ pub fn inflate_each( } /// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. -/// Every shape's membrane is solved at once (the eye depends on the highest puff); each solid is -/// built as the iterator reaches it. -pub(crate) fn inflate_faces( - shapes: &[(&PathData, FillRule)], - bounds: Rect, - options: Inflate, - view: View, - mesh: bool, -) -> impl Iterator<Item = Result<Built, String>> + use<> { +/// Every shape's membrane is solved at once (the eye depends on the highest puff), except with no +/// volume: nothing puffs then, and each shape's membrane is solved when its solid is taken. Each +/// solid is built as the iterator reaches it. +pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, mesh: bool) -> Solids { let o = options.sanitized(); + let later = o.volume == 0.0; + #[cfg(test)] + let later = later && !SOLVE_FIRST.with(std::cell::Cell::get); + if later { + let shapes: Vec<(PathData, FillRule)> = shapes.iter().map(|(path, rule)| ((*path).clone(), *rule)).collect(); + return Solids { pending: Pending::Later(shapes.into_iter()), bounds, o, view, reach: 0.0, mesh }; + } let puffs: Vec<Result<Option<Puff>, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); - puffs.into_iter().map(move |p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![], None)), - Err(e) => Err(e), - }) + Solids { pending: Pending::Solved(puffs.into_iter()), bounds, o, view, reach, mesh } +} + +/// The shapes' puffs still to take: every one solved when the shapes were laid out, or each +/// shape's path and fill rule, its puff solved when its solid is taken. +enum Pending { + Solved(std::vec::IntoIter<Result<Option<Puff>, String>>), + Later(std::vec::IntoIter<(PathData, FillRule)>), +} + +/// The solids of [`inflate_faces`], one result for each shape in order, each built as it is taken. +pub(crate) struct Solids { + pending: Pending, + bounds: Rect, + o: Inflate, + view: View, + /// The highest puff of the shapes drawn together. + reach: f64, + mesh: bool, +} + +impl Solids { + /// Pass over the next shape without building its solid, or solving its puff if that waits. + pub(crate) fn pass(&mut self) { + match &mut self.pending { + Pending::Solved(puffs) => { + puffs.next(); + } + Pending::Later(shapes) => { + shapes.next(); + } + } + } +} + +impl Iterator for Solids { + type Item = Result<Built, String>; + + fn next(&mut self) -> Option<Self::Item> { + let p = match &mut self.pending { + Pending::Solved(puffs) => puffs.next()?, + Pending::Later(shapes) => { + let (path, rule) = shapes.next()?; + check_bounds(self.bounds).and_then(|()| puff(&path, rule, self.bounds, &self.o)) + } + }; + Some(match p { + Ok(Some(p)) => inflated(p, self.bounds, self.o, self.view, self.reach, self.mesh), + Ok(None) => Ok((vec![], vec![], None)), + Err(e) => Err(e), + }) + } + + fn size_hint(&self) -> (usize, Option<usize>) { + match &self.pending { + Pending::Solved(puffs) => puffs.size_hint(), + Pending::Later(shapes) => shapes.size_hint(), + } + } } #[cfg(test)] thread_local! { + /// How many puffs this thread has solved, for the tests. + pub(crate) static PUFFED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; + /// Solve every shape's puff before the first solid is taken even with no volume, as with + /// volume, for the tests that compare the two. + pub(crate) static SOLVE_FIRST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; /// How many inflated solids this thread has built, for the tests. pub(crate) static INFLATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; /// The grid work of the last solid this thread inflated in full, for the tests. diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index e89a29afea..2a6d4786e9 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -1030,6 +1030,69 @@ fn a_flat_twisted_inflate_matches_the_extrusion() { } } +/// With no volume nothing puffs, so each shape's membrane is solved only when its solid is taken: +/// a caller that stops early (past the object's face limit) solves none after. With volume, the +/// eye waits on the highest puff, so every membrane is solved first. +#[test] +fn a_flat_inflate_solves_each_puff_as_its_solid_is_taken() { + let shapes: Vec<PathData> = (0..3).map(|i| shape(&square(120.0 * i as f64, 0.0, 100.0)).0).collect(); + let shapes: Vec<(&PathData, FillRule)> = shapes.iter().map(|p| (p, FillRule::NonZero)).collect(); + let b = Rect::new(0.0, 0.0, 340.0, 100.0); + let puffed = || PUFFED.with(std::cell::Cell::get); + let start = puffed(); + let mut each = inflate_each(&shapes, b, Inflate { depth: 20.0, volume: 0.0, ..Inflate::default() }, front()); + assert_eq!(puffed(), start, "no puff is solved before the first solid is taken"); + assert!(each.next().is_some_and(|f| f.is_ok_and(|f| !f.is_empty()))); + assert_eq!(puffed(), start + 1, "the later puffs wait until their solids are taken"); + drop(each); + assert_eq!(puffed(), start + 1); + let start = puffed(); + let each = inflate_each(&shapes, b, Inflate { depth: 20.0, ..Inflate::default() }, front()); + assert_eq!(puffed(), start + 3, "with volume, every puff places the eye"); + assert_eq!(each.count(), 3); +} + +/// With no volume, solving each puff as its solid is taken gives the very solids that solving +/// every puff first gives: their facets, their faces before projection and their projection, bit +/// for bit, with an empty shape and one that cannot be inflated among them. +#[test] +fn a_flat_inflate_solved_as_taken_matches_one_solved_first() { + let mut frame = square(-10.0, -10.0, 260.0); + frame.extend(square(-8.0, -8.0, 256.0)); + let mut circle = BezPath::new(); + circle.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((180.0, 60.0), 50.0), 0.01)); + let mut bad = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true); + bad.anchors[1].h_out = Point::new(f64::NAN, 0.0); + let paths = [shape(&square(0.0, 0.0, 100.0)).0, shape(&frame).0, PathData::new(vec![]), PathData::new(vec![bad]), shape(&circle).0]; + let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect(); + let b = Rect::new(-10.0, -10.0, 250.0, 250.0); + let turned = View { rotation_x: -20.0, rotation_y: 30.0, ..front() }; + let options = [ + Inflate { volume: 0.0, ..Inflate::default() }, + Inflate { volume: 0.0, depth: 40.0, twist: 90.0, taper: 0.5, ..Inflate::default() }, + Inflate { volume: 0.0, depth: 10.0, both_sides: true, cap: false, ..Inflate::default() }, + ]; + for view in [front(), turned, View { perspective: 60.0, ..turned }, View { perspective: 100.0, rotation_y: 150.0, ..turned }] { + for o in options { + for mesh in [false, true] { + let solids = |first: bool| { + SOLVE_FIRST.with(|s| s.set(first)); + let out: Vec<Result<build::Built, String>> = inflate_faces(&shapes, b, o, view, mesh).collect(); + SOLVE_FIRST.with(|s| s.set(false)); + out + }; + let (taken, first) = (solids(false), solids(true)); + assert_eq!(taken.len(), 5); + // Without a mesh the solid keeps its facets, with one its faces before projection. + let built = |r: &Result<build::Built, String>| r.as_ref().is_ok_and(|(f, m, _)| if mesh { !m.is_empty() } else { !f.is_empty() }); + assert!(built(&taken[0]) && built(&taken[1]) && built(&taken[4]), "{o:?} {view:?} mesh {mesh}"); + assert!(taken[2].as_ref().is_ok_and(|(f, m, p)| f.is_empty() && m.is_empty() && p.is_none()) && taken[3].is_err()); + assert_eq!(format!("{taken:?}"), format!("{first:?}"), "{o:?} {view:?} mesh {mesh}"); + } + } + } +} + /// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole /// (the middle is filled, as the art is drawn); one running the other way does, and so does /// either under the even-odd rule. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..c57e4f3cce 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1250,6 +1250,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). - The solids of one Extrude & Bevel (both kinds) or Inflate effect on one object, its fill's, its strokes' and a group's members' together, keep up to 163,840 faces (VectorCraft's own limit). Each solid is built in turn; once their faces pass the limit, no later solid is built, the object draws its source art, and applying or editing the effect, Export 3D Object and Expand as Wireframes report that the 3D surface is too complex. Tests `solids::tests::an_objects_solids_count_their_faces_together`, `a_groups_solids_together_past_the_limit_draw_the_source`, `a_fill_and_strokes_together_past_the_limit_draw_the_source`. + - Inflate at a Volume of 0 % works out each shape's puff only when its solid is built, so no puff is worked out for the shapes after the face limit stops, and the solids are the same bit for bit; with any volume every puff is worked out first, as the highest one places the eye. The shadow builds no solid for a fully transparent fill or stroke. Tests `solid::tests::a_flat_inflate_solves_each_puff_as_its_solid_is_taken`, `a_flat_inflate_solved_as_taken_matches_one_solved_first`, `shadow::tests::an_inflate_shape_that_casts_nothing_builds_no_solid`. - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). diff --git a/docs/three-d.md b/docs/three-d.md index 6de07fc2fe..6375fe9ec7 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -169,6 +169,10 @@ The rest is our reading: once their faces pass it no later solid is built: the object draws its source art, and applying or editing the effect, Export 3D Object and **Expand as Wireframes** report that the 3D surface is too complex. A stroke left out for its own limits does not count. +- Inflate works out how high every shape puffs before it builds the first solid, because the highest puff sets + how far away the eye is. At a Volume of 0 % no shape puffs, so each shape's puff is worked out only when its + solid is built, and none is worked out for the solids after the limit stops. The shadow builds no solid for a + fill or stroke that is fully transparent. ## Groups From a903feb870f08fc224b7be236d2cdbaf358587fb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 15:43:53 -0800 Subject: [PATCH 25/89] Dimension tool: no construction guides through a first point off the anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Smart Guides on, the Dimension tool's second point snapped onto the construction guides through the first point (Smart Guides › Construction Guides), so a click beside a segment straight below a first point that was not on an anchor stayed on the vertical guide instead of landing on the segment. The second point now has no construction guides through a first point that is not on an anchor. After a first point picked on an anchor, the construction guides through it still apply. The second point still snaps to anchors, centers and alignment lines, and with Snap to Perpendicular or Snap to Tangent on it lands where the line from the first point meets a segment square or grazes a curve. Docs: docs/dimensions.md, docs/target-app-parity.md. --- crates/engine/tests/it/dimensions.rs | 19 +++-- crates/tools/src/dimension.rs | 116 +++++++++++++++++++++++---- crates/tools/src/guides/snap.rs | 9 ++- docs/dimensions.md | 14 ++-- docs/target-app-parity.md | 3 +- 5 files changed, 129 insertions(+), 32 deletions(-) diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 56ee67be17..c0a047dda3 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -1721,8 +1721,9 @@ fn both_points_snap_onto_anchors_with_a_visible_indicator() { } /// Smart Guides for the Linear picks: hovering away from any line, the first point slides into -/// line with another object's corner and its guides show before the click; the second slides onto -/// the construction guide through the first. The dimension measures between the snapped points. +/// line with another object's corner and its guides show before the click; the second slides into +/// line with the other corner, with no construction guide through the first point pulling it +/// sideways. The dimension measures between the snapped points. #[test] fn linear_picks_show_and_snap_to_smart_guides() { use vectorcraft_engine::tools::Overlay; @@ -1738,18 +1739,20 @@ fn linear_picks_show_and_snap_to_smart_guides() { assert!(o.iter().any(|o| matches!(o, Overlay::Line { .. } | Overlay::Label { .. })), "its guides show before the click: {o:?}"); ev(&mut s, PointerKind::Down, 250.0, 202.5); ev(&mut s, PointerKind::Up, 250.0, 202.5); - // Straight below the first point, 1.5 pt off: the rubber band ends on the vertical through it. - ev(&mut s, PointerKind::Move, 251.5, 260.0); + // Below the first point, 1.5 pt to its right and 2.5 pt off the bottom edge's level: the + // rubber band ends on that level, where the pointer is across it. + ev(&mut s, PointerKind::Move, 251.5, 262.5); let end = overlays(&mut s).into_iter().find_map(|o| if let Overlay::Line { b, dashed: true, .. } = o { Some(b) } else { None }).unwrap(); - assert_eq!((end.x, end.y), (250.0, 260.0), "on the construction guide"); - ev(&mut s, PointerKind::Down, 251.5, 260.0); - ev(&mut s, PointerKind::Up, 251.5, 260.0); + assert_eq!((end.x, end.y), (251.5, 260.0), "in line with the bottom edge, not on a vertical through the first point"); + ev(&mut s, PointerKind::Down, 251.5, 262.5); + ev(&mut s, PointerKind::Up, 251.5, 262.5); ev(&mut s, PointerKind::Move, 220.0, 230.0); assert!(!overlays(&mut s).iter().any(|o| matches!(o, Overlay::Label { .. })), "placing: the guides are gone"); ev(&mut s, PointerKind::Down, 220.0, 230.0); ev(&mut s, PointerKind::Up, 220.0, 230.0); let id = s.doc().unwrap().selection.objects[0].0; - assert_eq!(text(&s, id), "60.00 pt"); + // Aligned from (250, 200) to (251.5, 260): √(1.5² + 60²). + assert_eq!(text(&s, id), "60.02 pt"); } /// The page content streams of exported PDF `v`, inflated. diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 14f7f85881..0840d92e08 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -240,12 +240,18 @@ fn points_source(cx: &ToolContext, (a, ra): (Point, &Option<PointRef>), (b, rb): impl DimensionTool { /// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as /// a link to its path); else where Smart Guides put it (another object's anchor or centre, in - /// line with one, or for the second point on a construction guide through the first), its - /// guides showing; else on the closest point of the line under the pointer; else `p` itself. + /// line with one, or for the second point square to a segment or grazing a curve from the + /// first with Snap to Perpendicular or Snap to Tangent on, or on a construction guide through + /// a first point picked on an anchor), its guides showing; else on the closest point of the + /// line under the pointer; else `p` itself. A first point not on an anchor has no + /// construction guides through it. /// A point the guides leave where the pointer is (right on a guide line) stays there, and with /// Snap to Pixel or Snap to Grid on a point stays where they place it. fn snap_linear(&mut self, cx: &ToolContext, p: Point) -> (Point, Option<PointRef>) { - let leave = self.points.last().map(|(first, _)| crate::guides::Leave::segment(cx, *first, false)); + let leave = self.points.last().map(|(first, anchor)| match anchor { + Some(_) => crate::guides::Leave::segment(cx, *first, false), + None => crate::guides::Leave::contact(*first), + }); let guided = self.snap.hover(cx, p, &[], leave.as_ref()); if let Some((point, reference)) = anchor_under(cx, p) { self.snap.clear(); @@ -846,10 +852,11 @@ mod tests { } } - /// A point that Smart Guides leave where the pointer already is (the pointer right on a - /// construction guide or in line with a target) stays on the guide the overlays show. It - /// does not move to the nearest point of a segment within the pick's reach. Off the guides, - /// a point near a segment still lands on it. + /// A point that Smart Guides leave where the pointer already is (the pointer right in line + /// with a target) stays on the guide the overlays show. It does not move to the nearest point + /// of a segment within the pick's reach. Off the guides, a point near a segment still lands + /// on it, also straight below the first point: the second point has no construction guide + /// through a first point that is not on an anchor. #[test] fn a_point_on_a_guide_stays_on_the_guide() { let (d, _) = doc_with_rect(); @@ -867,17 +874,22 @@ mod tests { // Off the guides, beside the edge: the closest point of the edge. let (on_edge, reference) = t.snap_linear(&c, Point::new(206.0, 170.5)); assert!(on_edge.distance(Point::new(200.0, 170.5)) < 1e-4 && reference.is_none(), "{on_edge:?}"); - // The first point, above the rectangle, then straight down along the vertical - // construction guide through it, past the right edge. + // The first point, above the rectangle, then straight down past the right edge: no + // vertical guide through the first point holds the second, so beside the edge it lands + // on the edge, and in line with the center it stays in line. click(&mut t, 206.0, 50.0); assert_eq!(t.points, vec![(Point::new(206.0, 50.0), None)]); - for y in [150.0, 170.5, 190.0] { - assert_eq!(t.snap_linear(&c, Point::new(206.0, y)), (Point::new(206.0, y), None), "on the construction guide at y = {y}"); - assert!(!t.snap.guides().is_empty()); + for y in [170.5, 190.0] { + let (q, reference) = t.snap_linear(&c, Point::new(206.0, y)); + assert!(q.distance(Point::new(200.0, y)) < 1e-4 && reference.is_none(), "beside the edge at y = {y}: {q:?}"); } - click(&mut t, 206.0, 170.5); + assert_eq!(t.snap_linear(&c, Point::new(207.0, 300.0)), (Point::new(207.0, 300.0), None), "away from the art"); + assert!(t.snap.guides().is_empty()); + assert_eq!(t.snap_linear(&c, Point::new(206.0, 150.0)), (Point::new(206.0, 150.0), None)); + assert!(!t.snap.guides().is_empty()); + click(&mut t, 206.0, 150.0); let dim = t.pending.as_ref().expect("both points were picked"); - assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 170.5))); + assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 150.0))); // The second point, off the edge, is held against the rectangle's anchors. let Some(DimensionSource::Points { free_b: Some(free), .. }) = &dim.source else { panic!("{:?}", dim.source) }; assert!(matches!(free, FreePoint::Anchors { .. }), "{free:?}"); @@ -894,6 +906,82 @@ mod tests { assert_eq!(DimensionTool::default().snap_linear(&pixel, Point::new(206.0, 170.0)), (Point::new(206.0, 170.0), None)); } + /// After a first point that is not on an anchor, the second point still lands where the line + /// from the first point meets a slanted segment square or grazes a curve, with the + /// "perpendicular" or "tangent" label, at places no construction guide through the first + /// point reaches. With Snap to Perpendicular and Snap to Tangent off it stays at the pointer. + #[test] + fn the_second_point_lands_square_to_a_segment_or_grazing_a_curve() { + use vectorcraft_doc::Appearance; + use vectorcraft_geom::{Rect, shapes}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (line, circle) = (d.alloc_id(), d.alloc_id()); + let slanted = shapes::line(Point::new(300.0, 100.0), Point::new(400.0, 150.0)); + d.insert(Some(l), 0, Node::path(line, slanted, Appearance::default_art())).unwrap(); + let round = shapes::ellipse(Rect::new(420.0, 370.0, 480.0, 430.0)); + d.insert(Some(l), 0, Node::path(circle, round, Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let first = Point::new(300.0, 250.0); + let picked = |c: &ToolContext| { + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(c, &PointerEvent::new(kind, first.x, first.y)); + } + assert_eq!(t.points, vec![(first, None)]); + t + }; + let labeled = |t: &DimensionTool, label: &str| t.snap.guides().iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == label)); + // The foot of the perpendicular from (300, 250) to the line is (360, 130), along no + // construction guide angle from the first point. + let mut t = picked(&c); + let (q, reference) = t.snap_linear(&c, Point::new(361.0, 132.0)); + assert!(q.distance(Point::new(360.0, 130.0)) < 1e-4 && reference.is_none(), "{q:?}"); + assert!(labeled(&t, "perpendicular"), "{:?}", t.snap.guides()); + // Where a line from the first point grazes the circle about (450, 400), radius 30. + let (center, radius) = (Point::new(450.0, 400.0), 30.0); + let away = first - center; + let angle = away.y.atan2(away.x) + (radius / away.hypot()).acos(); + let touch = center + Vec2::new(angle.cos(), angle.sin()) * radius; + let (q, _) = t.snap_linear(&c, touch + Vec2::new(0.5, 0.5)); + assert!((q.distance(center) - radius).abs() < 0.05, "{q:?} is on the circle"); + assert!(((q - first).normalize().dot((q - center).normalize())).abs() < 0.01, "{q:?} grazes the circle"); + assert!(labeled(&t, "tangent"), "{:?}", t.snap.guides()); + // With both options off, the pointer near the foot lands on the closest point of the line + // instead. + let off = ToolContext { snap_perpendicular: false, snap_tangent: false, ..cx(&d, &s, &p) }; + let mut t = picked(&off); + let (q, _) = t.snap_linear(&off, Point::new(361.0, 132.0)); + assert!(q.distance(Point::new(360.0, 130.0)) > 0.5 && !labeled(&t, "perpendicular"), "{q:?}"); + } + + /// After a first point picked on an anchor, the second point still lands on the construction + /// guides through it. + #[test] + fn the_second_point_after_an_anchor_lands_on_construction_guides() { + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &PointerEvent::new(kind, 200.0, 200.0)); + } + assert_eq!(t.points, vec![(Point::new(200.0, 200.0), Some(PointRef { node: id, subpath: 0, anchor: 2 }))]); + // 1.5 units right and below the 45° guide up and to the right of the anchor. + let (q, reference) = t.snap_linear(&c, Point::new(331.0, 72.0)); + assert!(q.distance(Point::new(329.5, 70.5)) < 1e-4 && reference.is_none(), "{q:?}"); + assert!(!t.snap.guides().is_empty()); + // The same offset from a first point off the anchors stays at the pointer. + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &PointerEvent::new(kind, 240.0, 240.0)); + } + assert_eq!(t.points, vec![(Point::new(240.0, 240.0), None)]); + assert_eq!(t.snap_linear(&c, Point::new(371.0, 112.0)), (Point::new(371.0, 112.0), None)); + assert!(t.snap.guides().is_empty()); + } + #[test] fn placement_press_previews_without_a_prior_move() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 006f498b6e..d7ee8ecdd8 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -257,10 +257,11 @@ impl Leave { self } - /// The straight segment from the anchor `at` to a point being dragged (an open path's end - /// moved with Direct Selection): the point may land square to a segment or grazing a curve, - /// but is not held to Shift's angles or the construction guides, which a moved anchor - /// does not follow. + /// The straight segment from the point `at` to a point being placed (an open path's end + /// moved with Direct Selection, or the Dimension tool's second Linear point after a first + /// point that is not on an anchor): the point may + /// land square to a segment or grazing a curve, but is not held to Shift's angles or the + /// construction guides through `at`. pub fn contact(at: Point) -> Self { Self { at, shift: None, angles: &[], contact: true } } diff --git a/docs/dimensions.md b/docs/dimensions.md index 9cba8a66dc..4c1d836182 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -15,13 +15,17 @@ The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers path's length. A press on an anchor picks it when the pointer moves less than 8 pixels before the release; a longer drag drags out the dimension of the segment under the pointer. A curved segment is measured from end to end. With Smart Guides on, both points also snap into line with other objects' - anchors and centres, and the second onto the construction guides through the first; the guides + anchors and centres. With Snap to Perpendicular or Snap to Tangent on, the second point also + snaps onto a segment where the line from the first meets it square or grazes a curve. When the + first point was picked on an anchor, the second also snaps onto the construction guides through + it; any other first point has no construction guides through it. The guides show while hovering, before each click. VectorCraft's own rules decide where a point lands: an anchor within 8 pixels takes it first; a point on a guide stays on the guide where the pointer - is, except that it moves onto a segment within the Smart Guides snapping tolerance, or onto the - place where a construction guide crosses another object's alignment line within twice that - tolerance; a point that no anchor, guide, grid or pixel snapping places lands on the closest - point of a segment within 8 pixels. + is, except that it moves onto a segment within the Smart Guides snapping tolerance, or, on a + construction guide or a Snap to Glyph guide that runs at an angle, onto the place where that + guide crosses another object's alignment line within twice that tolerance; a point that no + anchor, guide, grid or pixel snapping places lands on the closest point of a segment within 8 + pixels. A linear dimension is aligned, horizontal or vertical, as the [linear dimensions help](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/plot-linear-dimensions-of-objects.html) describes: after the second click, clicking again places an aligned dimension, and moving the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..e5235b880c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -591,7 +591,8 @@ since 2026-10-01. **Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape. **Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it. **Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it. -**Drawing tools, Dimension tool points on a Smart Guide stay on the guide:** By VectorCraft's own rule, a clicked point that lies on a Smart Guide already, such as a point straight below the first one on the vertical construction guide through it, or one in line with an object's center, stays where the pointer is when no anchor is within 8 pixels and no segment is within the Smart Guides snapping tolerance, instead of moving onto a segment farther than that tolerance and within 8 pixels; with Snap to Grid or Snap to Pixel on, a point stays on the grid or the whole pixel those options give. `a_point_on_a_guide_stays_on_the_guide` covers it. +**Drawing tools, Dimension tool points on a Smart Guide stay on the guide:** By VectorCraft's own rule, a clicked point that lies on a Smart Guide already, such as one in line with an object's center, stays where the pointer is when no anchor is within 8 pixels and no segment is within the Smart Guides snapping tolerance, instead of moving onto a segment farther than that tolerance and within 8 pixels; with Snap to Grid or Snap to Pixel on, a point stays on the grid or the whole pixel those options give. `a_point_on_a_guide_stays_on_the_guide` covers it. +**Drawing tools, Dimension tool second point without construction guides through a first point off the anchors:** By VectorCraft's own rule, with Smart Guides on, the second Linear point does not snap to the construction guides through a first point that is not on an anchor, so a click beside a segment straight below such a first point lands on the segment; after a first point picked on an anchor, the construction guides through it still apply. The second point still snaps to anchors, centers and alignment lines, and with Snap to Perpendicular or Snap to Tangent on, it lands where the line from the first point meets a segment square or grazes a curve. `a_point_on_a_guide_stays_on_the_guide`, `the_second_point_after_an_anchor_lands_on_construction_guides`, `the_second_point_lands_square_to_a_segment_or_grazing_a_curve` and `linear_picks_show_and_snap_to_smart_guides` cover it. **Drawing tools, Dimension tool dimensions selected with their art keep their lines and label:** Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave a dimension selected with art it measures, directly or in selected groups without fills, strokes or effects of their own, as it is and change only the other art; when the new art no longer fits its link, the dimension stays where it was, no longer linked, and undo links it again. `expand_leaves_a_dimension_selected_with_its_art_as_it_is`, `expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is`, `outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is`, `a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing`, `a_group_effect_expanded_reaches_the_dimension_it_holds`, `expand_keeps_a_dimension_in_its_asset_and_its_variable_binding`, `a_dimension_keeps_no_link_to_the_art_expand_replaced`, `a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced`, `a_dimension_outlasts_the_stroke_expand_outlined` and `expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art` cover it. **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. **Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo. From 660acb12ff8c07e5f3ba28192cf0d1e2e41bccb2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 21:09:12 -0800 Subject: [PATCH 26/89] Type: COLR radial gradients with a negative radius draw from their circle of radius 0 A radial gradient whose color line runs past 0 or 1 can get a circle with a negative radius once its stops are normalized to 0..1. Circles of a negative radius are not drawn: only the side of the circle of radius 0 (the tip of the cone) where the radius is positive is painted (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). Before: - As vector art, a padded gradient with a stop before 0 and its centers apart was focused on the start circle's center instead of the tip, and one with its centers together got stop offsets below 0. - Drawn into an image (a glyph with a sweep gradient, a Porter-Duff composite or a repeating gradient), any such gradient filled the glyph with its first stop's color. Both now recast the circles so neither radius is negative. With pad, the stops on the side that is not drawn become one stop at the tip with the color there; stops at the tip stay, so a sharp change of color there keeps the color on the drawn side. With repeat and reflect, the circles move by whole periods of the color line to the drawn side. When no circle is drawn, nothing is painted. --- crates/text/src/colr.rs | 107 ++++++++++++++++++++++++++++++-- crates/text/src/tests/colr.rs | 111 +++++++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 7 deletions(-) diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs index a3c6bf6e80..8e7a203ac0 100644 --- a/crates/text/src/colr.rs +++ b/crates/text/src/colr.rs @@ -306,13 +306,13 @@ impl<'a> Tree<'a> { self.unsupported.get_or_insert("a repeating or reflecting gradient"); return None; } - let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1)); + let (c0, r0, c1, r1, mut stops) = + non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), stops(color_stops, self), *extend)?; let concentric = (c0 - c1).hypot() < 1e-6; if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 { self.unsupported.get_or_insert("a radial gradient from a circle"); return None; } - let mut stops = stops(color_stops, self); if stops.iter().any(|s| !(0.0..=1.0).contains(&s.offset)) { self.unsupported.get_or_insert("a radial gradient with stops past its circles"); return None; @@ -357,6 +357,87 @@ fn normalized(mut stops: Vec<GradientStop>) -> Option<(Vec<GradientStop>, f32, f Some((stops, first, if span > 0.0 { last } else { first + 1.0 })) } +/// A radial gradient's circles (`c0`, `r0`) and (`c1`, `r1`) and color line `stops` (0 and 1 at +/// the circles) recast so that neither circle has a negative radius, as normalizing a color line +/// to 0..1 can give. Circles of a negative radius are not drawn, only those on the side of the +/// circle of radius 0 (the tip of the cone) where the radius is positive. With pad, the stops on +/// the other side become one stop at the tip with the color there; with repeat and reflect, the +/// circles move along by whole periods of the color line to the drawn side +/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). `None` +/// when no circle is drawn. +fn non_negative_radii( + c0: Point, + r0: f64, + c1: Point, + r1: f64, + mut stops: Vec<GradientStop>, + extend: Extend, +) -> Option<(Point, f64, Point, f64, Vec<GradientStop>)> { + if (r0 >= 0.0 && r1 >= 0.0) || stops.is_empty() { + return Some((c0, r0, c1, r1, stops)); + } + if r0 == r1 { + return None; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let tip = r0 / (r0 - r1); + let grows = r1 > r0; + let (from, to, stops) = match extend { + Extend::Repeat | Extend::Reflect => { + // A reflected color line repeats every 2: once forward, then back. + let (period, stops) = if extend == Extend::Reflect { + let forward = stops.iter().map(|s| GradientStop { offset: s.offset / 2.0, ..s.clone() }); + let back = stops.iter().rev().map(|s| GradientStop { offset: 1.0 - s.offset / 2.0, ..s.clone() }); + (2.0, forward.chain(back).collect()) + } else { + (1.0, stops) + }; + let from = if grows { (tip / period).ceil() * period } else { (tip / period).floor() * period - period }; + (from, from + period, stops) + } + _ => { + // The color line runs from 0 to 1, and further where its stops do. + let first = stops.first().map_or(0.0, |s| f64::from(s.offset).min(0.0)); + let last = stops.last().map_or(1.0, |s| f64::from(s.offset).max(1.0)); + let (from, to) = match (grows, tip) { + (true, t) if t < last => (t, last), + (true, t) => (t, t + 1.0), + (false, t) if t > first => (first, t), + (false, t) => (t - 1.0, t), + }; + let line = Gradient { stops, ..Gradient::default() }; + // The color at `w`, as a stop at `offset` of the new line. + let at = |w: f64, offset: f32| { + let (color, opacity) = line.sample(w as f32); + GradientStop { opacity, ..GradientStop::new(offset, color) } + }; + // Stops at the tip stay on the drawn side of its stop, so a sharp change of color there + // keeps the color on that side. + let mut kept: Vec<GradientStop> = line + .stops + .iter() + .filter(|s| (from - 1e-6..=to + 1e-6).contains(&f64::from(s.offset))) + .map(|s| GradientStop { offset: ((f64::from(s.offset) - from) / (to - from)).clamp(0.0, 1.0) as f32, ..s.clone() }) + .collect(); + if grows { + kept.insert(0, at(tip, 0.0)); + if kept.last().is_none_or(|s| s.offset < 1.0) { + kept.push(at(to, 1.0)); + } + } else { + kept.push(at(tip, 1.0)); + if kept.first().is_none_or(|s| s.offset > 0.0) { + kept.insert(0, at(from, 0.0)); + } + } + (from, to, kept) + } + }; + let circle = |w: f64| (c0.lerp(c1, w), (r0 + w * (r1 - r0)).max(0.0)); + let ((c0, r0), (c1, r1)) = (circle(from), circle(to)); + Some((c0, r0, c1, r1, stops)) +} + impl ColorPainter for Tree<'_> { fn push_transform(&mut self, transform: Transform) { let t = self.transform() * affine(&transform); @@ -549,10 +630,24 @@ impl ColorPainter for Pixels<'_, '_> { Brush::LinearGradient { p0, p1, color_stops, extend: e } => { peniko::Gradient::new_linear(point(p0), point(p1)).with_stops(stops(color_stops).as_slice()).with_extend(extend(e)).into() } - Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => peniko::Gradient::new_two_point_radial(point(c0), r0, point(c1), r1) - .with_stops(stops(color_stops).as_slice()) - .with_extend(extend(e)) - .into(), + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => { + let line = color_stops + .iter() + .map(|s| { + let (c, a) = colour(&self.palette, self.foreground, s.palette_index, s.alpha).unwrap_or((Color::BLACK, 0.0)); + GradientStop { opacity: a, ..GradientStop::new(s.offset, c) } + }) + .collect(); + let Some((c0, r0, c1, r1, line)) = non_negative_radii(point(c0), f64::from(r0), point(c1), f64::from(r1), line, e) else { return }; + let stops: Vec<peniko::ColorStop> = line + .iter() + .map(|s| { + let [r, g, b, a] = s.color.to_rgba8(s.opacity); + peniko::ColorStop::from((s.offset, peniko::Color::from_rgba8(r, g, b, a))) + }) + .collect(); + peniko::Gradient::new_two_point_radial(c0, r0 as f32, c1, r1 as f32).with_stops(stops.as_slice()).with_extend(extend(e)).into() + } Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => { peniko::Gradient::new_sweep(point(c0), start_angle.to_radians(), end_angle.to_radians()) .with_stops(stops(color_stops).as_slice()) diff --git a/crates/text/src/tests/colr.rs b/crates/text/src/tests/colr.rs index 212882ce7a..1516596f26 100644 --- a/crates/text/src/tests/colr.rs +++ b/crates/text/src/tests/colr.rs @@ -25,7 +25,7 @@ use vectorcraft_doc::color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{Node, NodeKind}; use crate::colr::{ColourContent, colour_glyph}; -use crate::test_fonts::{COLR_GREEN, COLR_RED}; +use crate::test_fonts::{COLR_BLUE, COLR_GREEN, COLR_RED}; const FOREGROUND: Color = Color::Rgb { r: 0.25, g: 0.5, b: 0.75 }; @@ -362,6 +362,115 @@ fn a_radial_gradient_from_a_circle_around_its_centre_is_vector_art() { assert_eq!(offsets, [0.25, 1.0], "the stops start a quarter of the way out"); } +/// A padded radial gradient whose color line starts before 0 starts at the circle of radius 0: +/// circles of a negative radius are not drawn, so the stops before that circle become one stop +/// there with the color between them +/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). With its +/// centers apart, the gradient is focused on the tip of its cone; with its centers together, it +/// is centered there with no stop below 0. +#[test] +fn a_radial_gradient_with_a_stop_before_0_starts_at_its_circle_of_radius_0() { + use vectorcraft_doc::color::{Gradient, GradientStop}; + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let half = F2Dot14::from_f32(0.5); + let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, half), ColorStop::new(one, 1, one)]); + let w = FWord::new; + // Half-opaque red at -0.5 and blue at 1: at 0, a third of the way from red to blue, in color + // and opacity. + let red = GradientStop { opacity: 0.5, ..GradientStop::new(0.0, rgb(COLR_RED)) }; + let tip = Gradient { stops: vec![red, GradientStop::new(1.0, rgb(COLR_BLUE))], ..Gradient::default() }.sample(1.0 / 3.0); + assert!((tip.1 - 2.0 / 3.0).abs() < 1e-6, "{tip:?}"); + for (family, c1_x, focal) in [("Colrtest Tip", 250, Some(300.0)), ("Colrtest Dot", 300, None)] { + let font = v1_font(family, COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(c1_x), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("{family}: a gradient") }; + let offsets: Vec<f32> = g.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 1.0], "{family}"); + let first = &g.gradient.stops[0]; + assert_eq!((first.color, first.opacity), tip, "{family}: the color at the circle of radius 0"); + assert_eq!(g.gradient.stops[1].color, rgb(COLR_BLUE), "{family}"); + let geom = g.geom.unwrap(); + assert!((geom.start.x - f64::from(c1_x)).abs() < 1e-3 && (geom.start.y + 300.0).abs() < 1e-3, "{family}: {geom:?}"); + assert!(((geom.end - geom.start).hypot() - 400.0).abs() < 1e-3, "{family}: {geom:?}"); + assert_eq!(geom.focal.map(|f| ((f.x * 1e3).round() / 1e3, (f.y * 1e3).round() / 1e3)), focal.map(|x| (x, -300.0)), "{family}"); + } +} + +/// A sharp change of color at the circle of radius 0 of a gradient whose color line starts +/// before 0 keeps the color beyond it: red up to 0, then green fading to blue. +#[test] +fn a_sharp_change_of_color_at_the_circle_of_radius_0_keeps_the_color_beyond_it() { + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let (one, zero) = (F2Dot14::from_f32(1.0), F2Dot14::from_f32(0.0)); + let stops = vec![ + ColorStop::new(F2Dot14::from_f32(-1.0), 0, one), + ColorStop::new(zero, 0, one), + ColorStop::new(zero, 2, one), + ColorStop::new(one, 1, one), + ]; + let line = ColorLine::new(Extend::Pad, 4, stops); + let w = FWord::new; + let font = v1_font("Colrtest Edge", COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(250), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("a gradient") }; + let stops = &g.gradient.stops; + let colors: Vec<Color> = stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [rgb(COLR_RED), rgb(COLR_RED), rgb(COLR_GREEN), rgb(COLR_BLUE)], "{stops:?}"); + assert!(stops[..3].iter().all(|s| s.offset.abs() < 1e-5) && stops[3].offset == 1.0, "{stops:?}"); +} + +/// A glyph drawn into an image draws a radial gradient with a circle of a negative radius from the +/// circle of radius 0, as vector art does: with pad, the color at the tip of the cone and the +/// colors on its drawn side, not one solid color. A composite that keeps only its backdrop makes +/// the glyph an image. Red runs to blue; the circles are around the `O`'s middle and larger than it. +#[test] +fn an_image_of_a_radial_gradient_with_a_stop_past_its_circle_of_radius_0_draws_from_that_circle() { + use write_fonts::tables::colr::{ColorLine, ColorStop, CompositeMode, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let w = FWord::new; + type Check = fn([u8; 3]) -> bool; + // Red at -0.5 (a start circle of radius -250 once normalized): the colors run from a third of + // the way to blue at the tip to blue. Blue at 1.5 (an end circle of radius -250): they run + // from red at the outer circle, which lies outside the `O`, to two thirds of the way to blue + // at the tip. Either way every opaque pixel holds some blue. + let cases: [(&str, f32, f32, u16, u16, Check); 2] = + [("Colrtest Tip Image", -0.5, 1.0, 0, 500, |[r, _, b]| b > 20 && r < 240), ("Colrtest Rim Image", 0.0, 1.5, 500, 0, |[_, _, b]| b > 20)]; + for (family, first, last, r0, r1, check) in cases { + let line = + ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(first), 0, one), ColorStop::new(F2Dot14::from_f32(last), 1, one)]); + let font = v1_font(family, COLOUR_CHAR, |o| { + let radial = ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(r0), w(290), w(330), UfWord::new(r1)); + ColrPaint::composite(ColrPaint::glyph(solid(2), o), CompositeMode::Dest, ColrPaint::glyph(radial, o)) + }); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") }; + let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap(); + let pixels: Vec<[u8; 3]> = pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect(); + assert!(pixels.len() > 100, "{family}: the `O` is drawn"); + let wrong: Vec<_> = pixels.iter().filter(|p| !check(**p)).take(5).collect(); + assert!(wrong.is_empty(), "{family}: {wrong:?}"); + } + // Repeat and reflect draw their bands, not one color. + for (family, extend) in [("Colrtest Repeat Image", Extend::Repeat), ("Colrtest Reflect Image", Extend::Reflect)] { + let line = ColorLine::new(extend, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, one), ColorStop::new(one, 1, one)]); + let font = v1_font(family, COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(0), w(290), w(330), UfWord::new(100)), o) + }); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") }; + let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap(); + let colors: std::collections::HashSet<[u8; 3]> = + pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect(); + assert!(colors.len() > 10, "{family}: {colors:?}"); + } +} + /// A glyph whose layers don't use the foreground colour is read once, whatever the type's fill. #[test] fn a_glyph_without_the_foreground_colour_is_read_once_for_every_fill() { From a98b8007616177d32197fe9a666942e5e7428045 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 22:31:48 -0800 Subject: [PATCH 27/89] Export: live effects in symbol art apply in PDF, EPS, print and DXF PDF, print, EPS and DXF wrote a symbol instance by placing its symbol's art as stored, so live effects inside that art were missing: a square moved 100 pt by a Transform effect stayed in place in the file while the canvas drew it moved. bake_document_placing_symbols replaces each instance of a symbol whose art (or the art of a symbol nested in it) needs baking by that art placed as the instance places it, then baked. The art is placed before its effects apply, as on the canvas, so an instance that scales its art does not scale what its effects do. A dynamic instance's own paint changes apply before baking. Nesting stops at 32 levels and the placed art at 16,000,000 objects per export; past them an instance stays an instance. DXF places 9-slice cells unclipped, as its scene does. The symbols' own art, SVG and the .ai editing data are unchanged, and HPGL already baked each placed instance. --- crates/cad/src/lib.rs | 3 +- crates/cad/src/tests.rs | 23 +++++ crates/effects/src/bake.rs | 139 ++++++++++++++++++++++++++++++- crates/effects/src/bake/tests.rs | 107 ++++++++++++++++++++++++ crates/effects/src/lib.rs | 4 +- crates/effects/src/reshape.rs | 2 +- crates/eps/src/lib.rs | 2 +- crates/eps/src/tests.rs | 17 ++++ crates/pdf/src/export/entry.rs | 5 +- crates/pdf/src/print.rs | 2 +- crates/pdf/src/tests.rs | 20 +++++ crates/pdf/src/tests/print.rs | 23 +++++ docs/target-app-parity.md | 1 + 13 files changed, 337 insertions(+), 11 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 66a6a36baa..d459f95b40 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -428,7 +428,8 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result<DxfOutput, String> { if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { return Err("the export region is not finite".into()); } - let baked = vectorcraft_effects::bake_document(doc); + // Symbols' art is placed without clipping, as the scene places it. + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, false); let doc = baked.as_ref().unwrap_or(doc); Ok(scene::Scene::new(doc, opts).run()) } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 96c037a4a7..fe1e909743 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1395,6 +1395,29 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // A line and the symbol's line below it, each moved 100 pt right by a Transform effect, start + // at the same x. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + n + }; + let mut d = doc_with(|d| { + let line = path(d, shapes::line((10.0, 10.0).into(), (20.0, 10.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + vec![moved(line), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })] + }); + let art = path(&mut d, shapes::line((10.0, 40.0).into(), (20.0, 40.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let starts: Vec<f64> = entities(&export(&d, &opts(&d)).unwrap()).iter().map(|e| all(e, 10).into_iter().fold(f64::INFINITY, f64::min)).collect(); + assert_eq!(starts.len(), 2, "{starts:?}"); + assert!((starts[0] - starts[1]).abs() < 1e-6, "{starts:?}"); +} + #[test] fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 7e42147443..d0cf98af26 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -17,7 +17,8 @@ //! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by //! the caller). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use std::sync::Arc; use vectorcraft_doc::color::Paint; @@ -26,7 +27,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{as_art, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, @@ -113,13 +114,105 @@ thread_local! { /// The objects of symbol art that [`bake_document`] has baked for instances inside the art of /// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`]. static NESTED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; + /// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their + /// placed art ([`placed_symbol_art`]). + static PLACING: std::cell::RefCell<Option<Rc<Placing>>> = const { std::cell::RefCell::new(None) }; + /// The objects of symbol art that [`placed_symbol_art`] has placed for one export, counted + /// against [`vectorcraft_doc::MAX_SYMBOL_NODES`]. + static PLACED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; +} + +/// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and +/// how that art is placed through 9-slice guides. +struct Placing { + /// The names of the symbols whose art draws something that baking changes, directly or through + /// the instances of other such symbols in it ([`drawn_symbols`]). + symbols: HashSet<String>, + /// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not + /// ([`Document::place_symbol_art_unclipped`]). + clip_slices: bool, +} + +/// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art +/// holds an instance of one of them at any depth. +fn drawn_symbols(d: &Document) -> HashSet<String> { + let mut out: HashSet<String> = d.symbols.iter().filter(|s| needs_bake(&s.art)).map(|s| s.name.clone()).collect(); + if out.is_empty() { + return out; + } + let uses: Vec<(&str, Vec<String>)> = d + .symbols + .iter() + .filter(|s| !out.contains(&s.name)) + .map(|s| { + let mut names = vec![]; + s.art.walk(&mut |c| { + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + names.push(symbol.clone()); + } + }); + (s.name.as_str(), names) + }) + .collect(); + loop { + let before = out.len(); + for (name, names) in &uses { + if !out.contains(*name) && names.iter().any(|x| out.contains(x)) { + out.insert((*name).to_string()); + } + } + if out.len() == before { + return out; + } + } +} + +/// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces +/// by its placed art? False outside it. +fn holds_placed_symbol(n: &Node) -> bool { + fn walk(n: &Node, symbols: &HashSet<String>) -> bool { + matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) + || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols))) + } + PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) +} + +/// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it +/// places, as the art it draws: the symbol's art with the instance's own appearance changes +/// ([`vectorcraft_doc::symbol_overrides::dynamic_art`]), placed by the instance as the exporters +/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and +/// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an +/// instance that scales its art does not scale what its effects do. `None` for other objects, +/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and once the art +/// placed for one export would pass [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects; those +/// instances stay as they are. +fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let clip = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| p.clip_slices))?; + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH { + return None; + } + let sym = d.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let most = match d.symbol_slice_map(symbol, *xf) { + Some(m) if clip => m.most_placed(&art, d), + Some(m) => m.most_placed_unclipped(&art, d), + None => art.count_with_masks() as u64, + }; + let spent = PLACED_NODES.with(|c| c.get()).saturating_add(most); + if spent > vectorcraft_doc::MAX_SYMBOL_NODES { + return None; + } + PLACED_NODES.with(|c| c.set(spent)); + let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; + Some(dressed(Node::group(n.id, vec![Arc::new(placed)]), n)) } /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option<Node> { // A cancelled export leaves the rest of the document as it is: the caller drops the result. // A ruler guide draws nothing and stays as it is, last in its layer. - if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. @@ -176,6 +269,14 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> { } return Some(baked.unwrap_or(m)); } + // Symbol instances whose art draws live effects, when the caller asks for them: the art + // placed, then baked. + if let Some(m) = placed_symbol_art(d, n) { + INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); + let baked = bake_node(d, &m); + INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); + return Some(baked.unwrap_or(m)); + } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning // every copy and its strokes and patterns can differ from the others'. @@ -693,8 +794,38 @@ fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes: /// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when /// there is nothing to do (export it as is). pub fn bake_document(doc: &Document) -> Option<Document> { + bake(doc, None) +} + +/// [`bake_document`], and every symbol instance whose symbol's art draws something baking changes +/// (live geometry effects, color adjustments and the rest [`needs_bake`] counts, in its own art or +/// in the art of the symbols it places) replaced by that art as the instance places it, baked +/// ([`placed_symbol_art`]), so the art draws as on the canvas wherever the instance is. For +/// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than +/// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped +/// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the +/// exporter places other instances. The symbols' own art is left as it is. +pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool) -> Option<Document> { + bake(doc, Some(clip_slices)) +} + +/// Restores what [`bake`] changes in the thread's baking state when it ends. +struct Restore(Option<Rc<Placing>>, u64); + +impl Drop for Restore { + fn drop(&mut self) { + PLACING.with(|p| *p.borrow_mut() = self.0.take()); + PLACED_NODES.with(|c| c.set(self.1)); + } +} + +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`. +fn bake(doc: &Document, placing: Option<bool>) -> Option<Document> { let placed = doc.with_placed_art(); - if !placed.layers.iter().any(|l| needs_bake(l)) { + let placing = placing.map(|clip_slices| Placing { symbols: drawn_symbols(&placed), clip_slices }).filter(|p| !p.symbols.is_empty()); + // A bake inside another one (of art an effect makes) places symbols only when it asks to. + let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0))); + if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), std::borrow::Cow::Borrowed(_) => None, diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 2bcc026340..72d210f6f3 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1025,3 +1025,110 @@ fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { // Three squares of their own, not one path spanning all three. assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); } + +/// A 10 × 10 square at (10, 40), with id `id`, that a Transform effect moves 100 pt right. +fn moved_square(id: u64) -> Node { + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + n +} + +fn instance(id: u64, symbol: &str, xf: vectorcraft_geom::Affine) -> Node { + Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); +} + +fn bounds(d: &Document, id: u64) -> Rect { + d.node(NodeId(id)).and_then(|n| n.geometric_bounds()).unwrap_or_else(|| panic!("node {id} has bounds")) +} + +fn near_rect(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +/// Each instance draws its symbol's art with the art's effects applied where the instance places +/// it: an instance that doubles the art's size moves it the effect's 100 pt, as the canvas draws +/// it, not 200 pt. Plain baking leaves the instances and the symbols' art as they are. +#[test] +fn placing_symbols_bakes_their_art_where_each_instance_places_it() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap(); + add_symbol(&mut d, "S", moved_square(800)); + assert!(bake_document(&d).is_none(), "nothing in the layers needs baking"); + let out = bake_document_placing_symbols(&d, true).unwrap(); + assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. })); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900)); + assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901)); + assert_eq!(out.symbols[0].art.appearance.effects.len(), 1, "the symbol's art keeps its effect"); +} + +/// An instance of a symbol whose art holds an instance of another symbol with effects draws that +/// art with its effects too. +#[test] +fn placing_symbols_reaches_the_art_of_nested_instances() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", moved_square(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))])); + let out = bake_document_placing_symbols(&d, true).unwrap(); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900)); +} + +/// A symbol whose art holds an instance of itself is placed until the depth limit, and the export +/// ends. +#[test] +fn placing_a_symbol_that_holds_itself_stops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Loop", Affine::IDENTITY)); + add_symbol( + &mut d, + "Loop", + Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]), + ); + let out = bake_document_placing_symbols(&d, true).unwrap(); + let mut lefts = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0))); + assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}"); + let mut depth = 0; + let mut n = out.node(NodeId(900)).unwrap(); + while let Some(next) = n.children().and_then(|ch| ch.last()) { + n = next; + depth += 1; + } + assert!(depth <= 3 * (vectorcraft_doc::depth::MAX_DEPTH + 1), "{depth}"); +} + +/// A dynamic symbol instance's own paint for an object of the art applies before the object's +/// effects are baked: a fill whose own Transform effect makes the square a group of pieces still +/// paints in the instance's red, and its sibling keeps the symbol's paint. +#[test] +fn placing_symbols_keeps_dynamic_instances_changes() { + use vectorcraft_geom::Affine; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let mut square = Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + if let Some(AppearanceItem::Fill(f)) = square.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + } + let sibling = Node::path(NodeId(801), shapes::rectangle(Rect::new(10.0, 60.0, 20.0, 70.0)), Appearance::default_art()); + let mut inst = instance(900, "D", Affine::IDENTITY); + inst.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(red.clone()), ..Default::default() }]; + let mut d = doc_with(inst); + add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)])); + d.symbols[0].dynamic = true; + let out = bake_document_placing_symbols(&d, true).unwrap(); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() + && let Some(b) = n.geometric_bounds() + { + fills.push((b.x0.round(), b.y0.round(), n.appearance.fill_paint())); + } + }); + assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}"); + assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6316a7a273..fb11a64161 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -68,7 +68,9 @@ pub use adjust::{ ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment, is_channel, }; -pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; +pub use bake::{ + StrokeArt, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake, +}; pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 8c829d9e0b..88df54fe5b 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -845,7 +845,7 @@ fn as_art_opts( /// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and /// remaining (raster) effects. -fn dressed(mut art: Node, n: &Node) -> Node { +pub(crate) fn dressed(mut art: Node, n: &Node) -> Node { art.name = n.name.clone(); art.visible = n.visible; art.opacity = n.opacity; diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 49b64f4bce..2dbadf4fea 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -230,7 +230,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb } // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); - let baked = vectorcraft_effects::bake_document(doc); + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); let mut setting_notes = vec![]; diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index c98cb9689a..22591bbe2b 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1409,6 +1409,23 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() { assert!(page.contains("1 0 0 rg"), "{page}"); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // The symbol's square at (10, 40), which a Transform effect moves 100 pt right, is drawn from + // x = 110 and not from x = 10. + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })]); + let mut art = path(&mut d, shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + let page = page_of(&d, &opts(&d)); + let starts: Vec<f64> = page.lines().filter(|l| l.ends_with(" m")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect(); + assert!(!starts.is_empty() && starts.iter().all(|x| (110.0..=120.0).contains(x)), "{starts:?}\n{page}"); +} + #[test] fn level_preview_and_overprint_ids_read_back() { for l in Level::ALL { diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index eabba52d9e..96dc97a6f8 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -45,8 +45,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let set = &opts.settings; set.check()?; vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; - // Live geometry effects export as their result; raster effects are reported below. - let baked = vectorcraft_effects::bake_document(doc); + // Live geometry effects export as their result, in symbols' art too; raster effects are + // reported below. + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true); // Baking stops early when cancelled, leaving a half-baked copy that must not be written. vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 12c71756c8..57d30e327d 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -183,7 +183,7 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) { /// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`] /// says. pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> { - let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned); + let mut doc = vectorcraft_effects::bake_document_placing_symbols(doc, true).map_or(Cow::Borrowed(doc), Cow::Owned); let d = doc.to_mut(); keep_layers(&mut d.layers, set.print_layers); composite_overprints(d, set); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d63aaedb35..d888d7b4b1 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -246,6 +246,26 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() { assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds()); } +/// The live effects in a symbol's art apply where the instance places the art, as on the canvas: +/// the symbol's square at (10, 40), which a Transform effect moves 100 pt right, is written at +/// (110, 40). +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + let mut d = doc(200.0, 100.0); + let mut square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + square.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(square) }); + add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); +} + /// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas /// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20. #[test] diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 5c5e1eb2c0..cefc255db9 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -451,6 +451,29 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() { assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}"); } +#[test] +fn symbol_art_effects_print_where_they_draw() { + // A red square and the symbol's blue square, each moved 100 pt right by a Transform effect, + // print at the same distance from the page's left edge. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"moveH": 100.0}), visible: true }); + n + }; + let mut d = doc(200.0, 100.0, &[]); + let (layer, id) = (d.default_layer().unwrap(), d.alloc_id()); + let red = Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0)); + d.insert(Some(layer), 0, moved(red)).unwrap(); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0)); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Moved".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let p = pages(&job(&d, json!({})).bytes); + assert_eq!(p.len(), 1); + let left = |c: [u8; 3]| p[0].1.iter().find(|(_, paint)| rgb8(paint) == c).map(|(r, _)| r.x0); + let (red, blue) = (left([255, 0, 0]), left([0, 0, 255])); + assert!(red.zip(blue).is_some_and(|(r, b)| (r - b).abs() < 0.05), "{p:?}"); +} + #[test] fn copies_collate_and_reverse() { let mut d = diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..06b745eec8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1552,6 +1552,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`). The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. From 897b4f0ecf22197a26f4bbadf0f04b041fd5f24f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 23:02:02 -0800 Subject: [PATCH 28/89] Export: placed symbol art counts its baked objects and drawn depth PDF, print, EPS and DXF exports now check the art of each placed symbol instance after its effects are baked. The objects it holds, including the copies a Transform effect makes, opacity mask art and the art of instances placed inside it (counted once), count against the export's 16,000,000-object budget. An instance whose baked art passes the budget, or would draw deeper than the 128 levels a document may draw, stays an instance. - doc: DrawnHeights measures how deep an object outside the layers draws, symbols and patterns it uses included. - effects: the bake tracks how deep each baked object sits, and a rejected candidate releases what it and the instances in it charged. --- crates/doc/src/depth.rs | 58 +++++++++++++++++++++++ crates/effects/src/bake.rs | 81 ++++++++++++++++++++++++-------- crates/effects/src/bake/tests.rs | 72 ++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 193 insertions(+), 20 deletions(-) diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 63b7e0ce7c..c846dfc1c6 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -251,6 +251,45 @@ fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool { heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH)) } +/// How deep the art of a document's symbols and patterns draws, to measure objects that use them +/// outside the document's layers ([`Self::deepest`]). +#[derive(Clone, Debug, Default)] +pub struct DrawnHeights { + symbols: HashMap<String, usize>, + patterns: HashMap<String, usize>, +} + +impl DrawnHeights { + /// The heights of `doc`'s symbols and patterns, as [`Document::too_deep`] counts them: the art + /// is 1 deep, and what it uses is counted under it. A symbol or pattern that uses itself, or + /// draws deeper than [`MAX_DRAWN_DEPTH`], has none. + pub fn of(doc: &Document) -> Self { + let mut out = Self::default(); + for ((pattern, name), h) in heights(doc) { + if let Some(h) = h { + let map = if pattern { &mut out.patterns } else { &mut out.symbols }; + map.insert(name.to_string(), h); + } + } + out + } + + /// The deepest `n`, placed `depth` deep, draws: its own objects and the art of their opacity + /// masks, and under the objects the art of the symbols and patterns they use. A use of a + /// symbol or pattern without a height ([`Self::of`]) counts as the object alone. `n`'s own + /// nesting may pass [`MAX_DEPTH`]. Without recursion. + pub fn deepest(&self, n: &Node, depth: usize) -> usize { + let mut out = depth; + let mut stack = vec![(n, depth)]; + while let Some((n, d)) = stack.pop() { + let used = uses(n).filter_map(|(pattern, name)| if pattern { self.patterns.get(name) } else { self.symbols.get(name) }).max(); + out = out.max(d + used.copied().unwrap_or(0)); + stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1))); + } + out + } +} + /// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object): /// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees /// within [`MAX_DEPTH`]. @@ -623,4 +662,23 @@ mod tests { d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")])); assert!(!d.too_deep(None) && !d.limit_depth()); } + + #[test] + fn drawn_heights_measure_objects_outside_the_layers() { + // s0 holds s1 two groups deep, which holds s2, which holds an instance of nothing known: + // s2 draws 3 deep, s1 6 and s0 9. + let d = symbols(3, 2, "none"); + let heights = DrawnHeights::of(&d); + let on_layer = instance(1, "s0"); + assert_eq!(heights.deepest(&on_layer, 2), 2 + 9); + // An object's own nesting and its mask's art count past MAX_DEPTH. + let mut deep = Node::group(NodeId(5), vec![nested(MAX_DEPTH + 10)]); + deep.extras_mut().mask = Some(Box::new(OpacityMask::new((*instance(6, "s2")).clone(), true))); + assert_eq!(heights.deepest(&deep, 1), 1 + 1 + MAX_DEPTH + 10); + let shallow = Node::group(NodeId(5), vec![instance(7, "s1")]); + assert_eq!(heights.deepest(&shallow, 100), 101 + 6); + // A symbol that holds itself has no height, so its instance counts alone. + let looped = DrawnHeights::of(&symbols(2, 1, "s0")); + assert_eq!(looped.deepest(&instance(1, "s0"), 2), 2); + } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 9a4c940f23..6e313eb26f 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -117,9 +117,20 @@ thread_local! { /// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their /// placed art ([`placed_symbol_art`]). static PLACING: std::cell::RefCell<Option<Rc<Placing>>> = const { std::cell::RefCell::new(None) }; - /// The objects of symbol art that [`placed_symbol_art`] has placed for one export, counted - /// against [`vectorcraft_doc::MAX_SYMBOL_NODES`]. + /// The objects in the baked art of the instances that [`bake_node`] has replaced by their + /// placed art for one export ([`placed_symbol_art`]), counted against [`Placing::most_nodes`]. static PLACED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; + /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer + /// is 1 deep. + static LEVEL: std::cell::Cell<usize> = const { std::cell::Cell::new(1) }; +} + +/// `f` run for an object one level deeper ([`LEVEL`]) than the one being baked. +fn one_deeper<T>(f: impl FnOnce() -> T) -> T { + LEVEL.with(|c| c.set(c.get() + 1)); + let out = f(); + LEVEL.with(|c| c.set(c.get().saturating_sub(1))); + out } /// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and @@ -131,6 +142,27 @@ struct Placing { /// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not /// ([`Document::place_symbol_art_unclipped`]). clip_slices: bool, + /// Most objects the baked art of all placed instances may hold together: + /// [`vectorcraft_doc::MAX_SYMBOL_NODES`] for an export. + most_nodes: u64, + /// How deep the document's symbols and patterns draw, for the instances and pattern paints + /// left in placed art. + heights: vectorcraft_doc::depth::DrawnHeights, +} + +/// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, +/// when the art placed for the export so far holds `spent` objects? `Some` with the objects +/// placed then, when its objects (opacity masks' art included) fit within +/// [`Placing::most_nodes`] and nothing in it, the symbols and patterns it uses included, draws +/// deeper than [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`]. +fn placed_fits(baked: &Node, spent: u64) -> Option<u64> { + PLACING.with(|p| { + let p = p.borrow(); + let p = p.as_ref()?; + let spent = spent.saturating_add(baked.count_with_masks() as u64); + let deepest = p.heights.deepest(baked, LEVEL.with(|c| c.get())); + (spent <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) + }) } /// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art @@ -183,12 +215,13 @@ fn holds_placed_symbol(n: &Node) -> bool { /// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and /// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an /// instance that scales its art does not scale what its effects do. `None` for other objects, -/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and once the art -/// placed for one export would pass [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects; those -/// instances stay as they are. +/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art +/// as placed, before its effects are baked, would already take the art placed for the export +/// past [`Placing::most_nodes`]; those instances stay as they are. [`bake_node`] checks the baked +/// art with [`placed_fits`]. fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; - let clip = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| p.clip_slices))?; + let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH { return None; } @@ -199,11 +232,9 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { Some(m) => m.most_placed_unclipped(&art, d), None => art.count_with_masks() as u64, }; - let spent = PLACED_NODES.with(|c| c.get()).saturating_add(most); - if spent > vectorcraft_doc::MAX_SYMBOL_NODES { + if PLACED_NODES.with(|c| c.get()).saturating_add(most) > most_nodes { return None; } - PLACED_NODES.with(|c| c.set(spent)); let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; Some(dressed(Node::group(n.id, vec![Arc::new(placed)]), n)) } @@ -272,12 +303,20 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> { return Some(baked.unwrap_or(m)); } // Symbol instances whose art draws live effects, when the caller asks for them: the art - // placed, then baked. + // placed, then baked. When the baked art passes the export's budget, or would draw too deep + // where the instance is, it is dropped with the art placed for the instances inside it, and + // the instance stays as it is. if let Some(m) = placed_symbol_art(d, n) { + let spent = PLACED_NODES.with(|c| c.get()); INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); let baked = bake_node(d, &m); INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); - return Some(baked.unwrap_or(m)); + let baked = baked.unwrap_or(m); + if let Some(now) = placed_fits(&baked, spent) { + PLACED_NODES.with(|c| c.set(now)); + return Some(baked); + } + PLACED_NODES.with(|c| c.set(spent)); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning @@ -300,7 +339,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> { return bake_leaf(d, n); } let mut m = n.clone(); - let baked: Vec<Arc<Node>> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); + let baked: Vec<Arc<Node>> = ch.iter().map(|c| one_deeper(|| bake_node(d, c)).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); if let Some(ch) = m.children_mut() { *ch = baked; } @@ -319,7 +358,7 @@ fn bake_pieces(d: &mut Document, mut m: Node) -> Node { .into_iter() .map(|c| { let c = Arc::unwrap_or_clone(c); - let mut c = bake_node(d, &c).unwrap_or(c); + let mut c = one_deeper(|| bake_node(d, &c)).unwrap_or(c); fresh_ids(d, &mut c, &mut seen); Arc::new(c) }) @@ -808,25 +847,29 @@ pub fn bake_document(doc: &Document) -> Option<Document> { /// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the /// exporter places other instances. The symbols' own art is left as it is. pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool) -> Option<Document> { - bake(doc, Some(clip_slices)) + bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES))) } /// Restores what [`bake`] changes in the thread's baking state when it ends. -struct Restore(Option<Rc<Placing>>, u64); +struct Restore(Option<Rc<Placing>>, u64, usize); impl Drop for Restore { fn drop(&mut self) { PLACING.with(|p| *p.borrow_mut() = self.0.take()); PLACED_NODES.with(|c| c.set(self.1)); + LEVEL.with(|c| c.set(self.2)); } } -/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`. -fn bake(doc: &Document, placing: Option<bool>) -> Option<Document> { +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices` +/// and [`Placing::most_nodes`]. +fn bake(doc: &Document, placing: Option<(bool, u64)>) -> Option<Document> { let placed = doc.with_placed_art(); - let placing = placing.map(|clip_slices| Placing { symbols: drawn_symbols(&placed), clip_slices }).filter(|p| !p.symbols.is_empty()); + let placing = placing.map(|(clip_slices, most_nodes)| (drawn_symbols(&placed), clip_slices, most_nodes)).filter(|p| !p.0.is_empty()).map( + |(symbols, clip_slices, most_nodes)| Placing { symbols, clip_slices, most_nodes, heights: vectorcraft_doc::depth::DrawnHeights::of(&placed) }, + ); // A bake inside another one (of art an effect makes) places symbols only when it asks to. - let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0))); + let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0)), LEVEL.with(|c| c.replace(1))); if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index b0ebfc4eba..f72637b6f6 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1172,3 +1172,75 @@ fn placing_symbols_keeps_dynamic_instances_changes() { assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}"); assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); } + +fn is_instance(d: &Document, id: u64) -> bool { + d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) +} + +/// The art placed for an export is counted as baked: the copies an effect makes count, so an +/// instance whose baked art passes what is left of the budget stays an instance, and the budget +/// holds the baked art of the instances placed, exactly. +#[test] +fn placing_symbols_counts_the_baked_art() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)))).unwrap(); + add_symbol(&mut d, "S", gradient_copies(800)); + let all = bake(&d, Some((true, u64::MAX))).unwrap(); + let each = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + assert!(each > 2, "the instance's group, and the copies as objects of their own: {each}"); + assert_eq!(all.node(NodeId(901)).map(|n| n.count_with_masks() as u64), Some(each)); + let fits = bake(&d, Some((true, 2 * each))).unwrap(); + assert!(!is_instance(&fits, 900) && !is_instance(&fits, 901)); + let short = bake(&d, Some((true, 2 * each - 1))).unwrap(); + assert_eq!(usize::from(is_instance(&short, 900)) + usize::from(is_instance(&short, 901)), 1, "one instance stays as it is"); +} + +/// The baked art of an instance placed inside another's art is counted once: a budget of exactly +/// the outer instance's baked art places both. +#[test] +fn placing_symbols_counts_nested_art_once() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", gradient_copies(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::IDENTITY))])); + let all = bake(&d, Some((true, u64::MAX))).unwrap(); + let total = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let exact = bake(&d, Some((true, total))).unwrap(); + assert!(!is_instance(&exact, 900) && !is_instance(&exact, 802), "both are placed within {total}"); + let short = bake(&d, Some((true, total - 1))).unwrap(); + assert!(is_instance(&short, 900), "the outer instance stays as it is"); +} + +/// `leaf` in `levels` groups, with ids from `id` up. +fn in_groups(id: u64, levels: u64, leaf: Node) -> Node { + (0..levels).fold(leaf, |n, i| Node::group(NodeId(id + i), vec![Arc::new(n)])) +} + +/// Placed art is drawn no deeper than the document may draw: an instance whose art's effects add +/// a level past it stays an instance, and the instances around it are placed. +#[test] +fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { + use vectorcraft_doc::depth::{DrawnHeights, MAX_DRAWN_DEPTH}; + use vectorcraft_geom::Affine; + // The instance on the layer is 2 deep; A to D each hold the next 31 objects down, and E's + // square is 2 deep in its art: 2 + 4 × 31 + 2 = MAX_DRAWN_DEPTH. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!d.too_deep(None)); + assert_eq!(DrawnHeights::of(&d).deepest(&d.layers[0], 1), MAX_DRAWN_DEPTH); + let out = bake_document_placing_symbols(&d, true).unwrap(); + // E's square becomes a group of copies, a level past the cap: E's instance stays. + assert!(is_instance(&out, 4100), "E's instance stays as it is"); + assert!(!is_instance(&out, 900) && !is_instance(&out, 3100), "A to D are placed"); + assert!(DrawnHeights::of(&out).deepest(&out.layers[0], 1) <= MAX_DRAWN_DEPTH); + // Without the chain above it, E's art is placed. + let mut shallow = doc_with(instance(900, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!is_instance(&bake_document_placing_symbols(&shallow, true).unwrap(), 900)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c5d7314319..a5365c758c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1561,7 +1561,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`). The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. From 77070d29a7368863cae7ff556530a20a7cfbf08c Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:22:18 +0100 Subject: [PATCH 29/89] Pencil: the continuation cursor follows the snapped release; Precise Cursors tests keep the badges --- crates/engine/src/tests/prefs.rs | 12 +++++---- crates/tools/src/draw2/gesture.rs | 14 ++++++++-- crates/tools/src/draw2/gesture/tests.rs | 34 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index d88b8250a2..67a740d4a3 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -723,8 +723,10 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} off"); } s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap(); - for (tool, _) in own { - assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on"); + for (tool, c) in own { + // The Pencil's star is drawn on the crosshair and keeps its badge. + let want = if tool == "pencil" { c } else { Cursor::Crosshair }; + assert_eq!(cursor(&mut s, tool), want, "{tool} on"); } for tool in always { assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on"); @@ -758,8 +760,8 @@ fn use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer() { } } -/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors -/// turns those into a crosshair too. +/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors. Use Precise Cursors keeps +/// them: they are drawn on the crosshair, and the ring says the release closes the path. #[test] fn alt_shows_the_pencil_and_paintbrush_closing_cursors() { use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; @@ -773,7 +775,7 @@ fn alt_shows_the_pencil_and_paintbrush_closing_cursors() { s.select_tool(tool, ViewInfo::default()).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 250.0), ViewInfo::default()).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 300.0, 300.0), ViewInfo::default()).unwrap(); - assert_eq!(s.cursor(at, alt, ViewInfo::default()), if precise { Cursor::Crosshair } else { c }, "{tool}, precise {precise}"); + assert_eq!(s.cursor(at, alt, ViewInfo::default()), c, "{tool}, precise {precise}"); s.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 300.0), ViewInfo::default()).unwrap(); } } diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index fc57b4d953..c3191c4363 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -98,6 +98,9 @@ pub struct GestureTool { /// A release where the pointer last dragged to would close the stroke because it ends near /// its start (Close Paths When Ends Are Within): the cursor shows the closing ring. near_start: bool, + /// A release where the pointer last dragged to would connect the continued path to another + /// selected path's open end (`finish`): the cursor shows the continuation cursor. + connects: bool, /// Pencil/Paintbrush continuing a selected open path: (path, continue from its start). extend: Option<(NodeId, bool)>, /// The other end of the path `extend` continues: a release near it closes the path. @@ -819,6 +822,7 @@ impl GestureTool { active: false, began: false, near_start: false, + connects: false, extend: None, close_to: None, connect_to: vec![], @@ -1303,6 +1307,7 @@ impl Tool for GestureTool { self.active = true; self.began = false; self.near_start = false; + self.connects = false; // Alt switches the Pencil when its toggle is on, the Blob Brush always, and the // Paintbrush when the press has a selected path. self.smoothing = ev.mods.alt @@ -1380,10 +1385,12 @@ impl Tool for GestureTool { let kept = self.points.last().copied().filter(|l| l.distance(p) < cx.tol(1.0) && !self.snaps()); let release_end = kept.unwrap_or(end); self.near_start = self.release_closes_near_start(cx, release_end); + self.connects = self.connects_at(cx, release_end).is_some(); return vec![]; } self.push(p, ev); self.near_start = self.release_closes_near_start(cx, end); + self.connects = self.connects_at(cx, end).is_some(); if !self.draws_path() || self.smoothing { return vec![]; } @@ -1444,6 +1451,7 @@ impl Tool for GestureTool { self.active = false; self.began = false; self.near_start = false; + self.connects = false; self.smoothing = false; self.straight = false; self.unconstrained = false; @@ -1479,6 +1487,7 @@ impl Tool for GestureTool { self.active = false; self.began = false; self.near_start = false; + self.connects = false; self.smoothing = false; self.straight = false; self.unconstrained = false; @@ -1509,6 +1518,7 @@ impl Tool for GestureTool { self.snap.clear(); self.active = false; self.near_start = false; + self.connects = false; self.began = false; self.smoothing = false; self.straight = false; @@ -1597,8 +1607,8 @@ impl Tool for GestureTool { return if self.find_extend(cx, p).is_some() { Cursor::PencilContinue } else { Cursor::PencilStraight }; } // A continuing Pencil stroke that a release would connect to another selected path's open - // end (`finish`) shows that it continues on. - if self.id == "pencil" && self.active && self.points.last().is_some_and(|&end| self.connects_at(cx, end).is_some()) { + // end (`finish`) shows that it continues on; `connects` tests the point the release keeps. + if self.id == "pencil" && self.active && self.connects { return Cursor::PencilContinue; } // Alt held at the release closes the path (`finish`). diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 3bee270237..2159b8af2c 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -959,6 +959,40 @@ fn a_continued_stroke_shows_where_it_connects_or_closes() { assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "at 0 px"); } +/// The continuation cursor tests where the release ends, after snapping, not the pointer: here +/// Snap to Grid moves a stroke's end from 13 pt away from another selected path's end (outside Edit +/// Selected Paths' 12 px) to 9 pt away, so the release connects, and the cursor says so. +#[test] +fn the_continuation_cursor_follows_the_snapped_release() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| { + let id = d.alloc_id(); + let els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1))]; + let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + // On the 9 pt grid. + let a = add(&mut d, (306.0, 306.0), (405.0, 405.0)); + let b = add(&mut d, (504.0, 504.0), (603.0, 504.0)); + let p = paint(); + let mut both = Selection::default(); + both.set([a, b]); + let mut cx = cx(&d, &both, &p); + cx.smart_guides = false; + cx.snap_to_point = false; + cx.snap_to_grid = true; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 405.0, 405.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 460.0, 450.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 517.0, 504.0)); + assert_eq!(t.cursor(&cx, Point::new(517.0, 504.0), Mods::default()), Cursor::PencilContinue); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 517.0, 504.0)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["connect"], json!({"id": b.0, "end": "start"}), "{v}"); +} + /// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt /// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. #[test] From 2a5f2009d82af7808d1fa06a4d5ab90a68743cd1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 22:04:30 -0800 Subject: [PATCH 30/89] Graphs: Import Data reads UTF-16 text files without a byte-order mark The Graph Data window's Import Data read UTF-16 only after a byte-order mark, so a UTF-16 file without one filled the cells with NUL characters. It now reads such a file in the byte order Import Data Sets' rule (#7348) gives it: at least half of the two-byte units hold a NUL byte, and the byte order is the one that reads the line breaks. The text is then read as after a byte-order mark: an odd last byte is dropped and an unpaired surrogate reads as U+FFFD. A file the rule refuses is read as UTF-8, as before. The rule now also gives a byte order to a one-line file, which reads a line break in neither order: big-endian when more of its units start with a NUL byte than end with one, else little-endian as before. Import Data Sets still refuses a one-line file, which has no data rows; read in the new order, such a file can be refused with a different error first. --- crates/engine/src/cmd.rs | 4 +++ crates/engine/src/cmd/variablecmds.rs | 1 + crates/engine/src/cmd/variablecmds/csv.rs | 36 +++++++++++++++-------- crates/engine/src/tests/variables.rs | 10 +++++++ crates/ui-egui/src/dialogs/graph_data.rs | 32 ++++++++++++++++++-- docs/mcp.md | 5 ++-- docs/target-app-parity.md | 1 + 7 files changed, 72 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 94d8add68b..92ec32a617 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -874,6 +874,10 @@ pub use buildcmds::live_paint_gaps; /// Control bar enable it when it changes one). pub use brushsym::reset_transformation_changes; +/// The byte order of UTF-16 text without a byte-order mark, by Import Data Sets' rule (the Graph +/// Data window reads files by it too). +pub use variablecmds::utf16_order_without_mark; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 9acd31e664..5f8d6605b3 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -26,6 +26,7 @@ use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids mod csv; mod library; +pub use csv::utf16_order_without_mark; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; diff --git a/crates/engine/src/cmd/variablecmds/csv.rs b/crates/engine/src/cmd/variablecmds/csv.rs index 38dd42d922..be9db3b841 100644 --- a/crates/engine/src/cmd/variablecmds/csv.rs +++ b/crates/engine/src/cmd/variablecmds/csv.rs @@ -59,25 +59,35 @@ fn utf16(bytes: &[u8], le: bool) -> Option<String> { char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok().filter(|_| odd.is_empty()) } -/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of -/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with -/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is -/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or -/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either -/// order has no data rows, and is read little-endian so that the import reports that. The text -/// must be valid UTF-16 in that order and hold no NUL character. -fn utf16_without_mark(bytes: &[u8]) -> Option<String> { - let (units, odd) = bytes.as_chunks::<2>(); - if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() { +/// The byte order of `bytes`, which hold NUL bytes and no byte-order mark, read as UTF-16: +/// `Some(true)` for little-endian, or `None` when they don't read as UTF-16. At least half of the +/// two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with fewer, +/// such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is the one +/// that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or U+0D00, +/// so a file that reads one in both orders is refused. A file that reads none in either order (one +/// line) is big-endian when more of its units start with a NUL byte than end with one, as +/// characters from U+0001 to U+00FF do in that order, and little-endian otherwise. An odd last +/// byte is left out of the count. +pub fn utf16_order_without_mark(bytes: &[u8]) -> Option<bool> { + let units = bytes.as_chunks::<2>().0; + if units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() { return None; } let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D); let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c))); let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c))); - if lines_le && lines_be { - return None; + match (lines_le, lines_be) { + (true, true) => None, + (false, false) => Some(units.iter().filter(|c| c[0] == 0).count() <= units.iter().filter(|c| c[1] == 0).count()), + (_, lines_be) => Some(!lines_be), } - utf16(bytes, !lines_be).filter(|text| !text.contains('\0')) +} + +/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16 in the byte order +/// [`utf16_order_without_mark`] gives, or `None`. The file must have an even length, and the text +/// must be valid UTF-16 in that order and hold no NUL character. +fn utf16_without_mark(bytes: &[u8]) -> Option<String> { + utf16(bytes, utf16_order_without_mark(bytes)?).filter(|text| !text.contains('\0')) } /// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted. diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index eea8ae4986..6256088842 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2201,6 +2201,16 @@ fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() { let e = import(&mut s, &bytes).unwrap_err().to_string(); assert!(e.contains("CSV has a header but no data rows"), "{e}"); } + // With no line break, the side of each unit that holds the NUL byte gives the byte order, and a + // tie is little-endian. + let order = crate::cmd::utf16_order_without_mark; + assert_eq!(order(&utf16("City", false)), Some(false)); + assert_eq!(order(&utf16("City", true)), Some(true)); + assert_eq!(order(b"\0aa\0"), Some(true)); + assert_eq!(order(&[utf16("City\n", false), vec![0x0A]].concat()), Some(false), "an odd last byte is left out"); + // Read big-endian, this one-line file ends in an unpaired surrogate. + let e = import(&mut s, b"\0a\0b\xd8\0").unwrap_err().to_string(); + assert!(e.contains("CSV holds NUL bytes"), "{e}"); import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap(); assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目")); // Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 16d22d4430..2dbcfad8fe 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -147,7 +147,10 @@ pub(crate) fn grid_from_text(text: &str) -> Grid { } /// A text file's bytes as text: UTF-16 with its byte-order mark (spreadsheets' "Unicode Text"), -/// else UTF-8 without one. +/// else UTF-16 without one when the bytes hold NUL bytes and Import Data Sets' rule gives them a +/// byte order ([`vectorcraft_engine::cmd::utf16_order_without_mark`]), else UTF-8 with or without +/// one. UTF-16 is read as it is after a byte-order mark: an odd last byte is left out, and an +/// unpaired surrogate reads as U+FFFD. pub(crate) fn decode(bytes: &[u8]) -> String { let utf16 = |rest: &[u8], le: bool| { let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|p| if le { u16::from_le_bytes(*p) } else { u16::from_be_bytes(*p) }).collect(); @@ -157,7 +160,10 @@ pub(crate) fn decode(bytes: &[u8]) -> String { [0xFF, 0xFE, rest @ ..] => utf16(rest, true), [0xFE, 0xFF, rest @ ..] => utf16(rest, false), [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), - _ => String::from_utf8_lossy(bytes).into_owned(), + _ => match bytes.contains(&0).then(|| vectorcraft_engine::cmd::utf16_order_without_mark(bytes)).flatten() { + Some(le) => utf16(bytes, le), + None => String::from_utf8_lossy(bytes).into_owned(), + }, } } @@ -517,6 +523,28 @@ mod tests { assert_eq!(grid_from_text(&wide)[0].len(), MAX_COLS); } + #[test] + fn utf16_without_a_byte_order_mark_reads_by_its_line_breaks() { + let text = "\tSales é\r\nQ1\t1\n"; + let le: Vec<u8> = text.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let be: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + assert_eq!(decode(&le), text, "little-endian"); + assert_eq!(decode(&be), text, "big-endian"); + assert_eq!(grid_from_text(&decode(&le)), g(&[&["", "Sales é"], &["Q1", "1"]])); + // One row has no line break: the side of each unit that holds the NUL byte gives the order. + for (bytes, order) in [(b"\0Q\x001\0\t\x002".to_vec(), "big-endian"), (b"Q\x001\0\t\x002\0".to_vec(), "little-endian")] { + assert_eq!(decode(&bytes), "Q1\t2", "one line, {order}"); + } + // A file cut short by one byte, or with an unpaired surrogate, reads as it would after a + // byte-order mark. + assert_eq!(decode(&[le.as_slice(), &[0x0A]].concat()), text, "odd length"); + assert_eq!(decode(&[le.as_slice(), &[0x00, 0xD8]].concat()), format!("{text}\u{FFFD}"), "unpaired surrogate"); + // A line break in both byte orders leaves the order unknown, and UTF-8 with a stray NUL + // byte isn't UTF-16: both read as UTF-8, as before. + assert_eq!(decode(&[0x0A, 0, 0, 0x0A]), "\n\0\0\n"); + assert_eq!(decode(b"a,b\n1,2\0"), "a,b\n1,2\0"); + } + #[test] fn a_sheet_sent_wider_and_taller_than_a_graph_is_cut_to_one() { // One long row and one long column: their product would be ten billion cells. diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..a68645348e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3175,8 +3175,9 @@ already walks Image Trace. Short agent entry points: byte order that reads a line break (`\n` or `\r`). It is refused when fewer of its units hold a NUL byte (UTF-8 or Windows-1252 text with stray NUL bytes, or UTF-16 text made mostly of other characters, which needs a byte-order mark), when both byte orders read a line break (the text holds U+0A00 or U+0D00), when it has an odd length, and - when it isn't valid UTF-16 in that order or holds a NUL character. A file with no line break has no data rows and is - refused for that. `csv` and `data` are read as the text they hold. + when it isn't valid UTF-16 in that order or holds a NUL character. A file with no line break is read big-endian when + more of its two-byte units start with a NUL byte than end with one, and little-endian otherwise; it has no data rows, + so the import is refused. `csv` and `data` are read as the text they hold. - Variable libraries: `variable.loadLibrary {path|xml|dataBase64}` (the Variables panel menu's Load Variable Library…) replaces the document's variables and data sets with a variable library's in one undo step. A load, like a `dataset.import`, adds no undo step when it leaves the variables and data sets as they were. The library diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..8fe37830a2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -793,6 +793,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Export for Screens numbers distinct row files whose names collide, compared without case, as `-2`, `-3`… instead of leaving them out; identical rows still write one file. It puts row folders that differ only in case into one folder (`document.exportForScreens`). - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones. Every header cell is a variable, and the rows replace the document's data sets as Data Set 1, Data Set 2 and so on. An import error about a row's values gives the file line the row starts on. - Variables: Import Data Sets… (`dataset.import`) reads a UTF-16 file without a byte-order mark, instead of refusing it, when at least half of its two-byte units hold a NUL byte; it uses the byte order that reads its line breaks. + - Graphs: the Graph Data window's Import Data reads a UTF-16 text file without a byte-order mark by the same rule as Import Data Sets…, instead of filling the cells with NUL characters. It reads a file cut short by one byte, or with an unpaired surrogate, as it reads one after a byte-order mark. - Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused. - Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every data set is deleted, and every variable is deleted with its bindings and values, except a text variable the library also declares, which stays bound so the library's data sets change its objects (#4136); then the library's other text variables are defined, unbound, and its data sets take the text of each value's `p` elements, one paragraph each. Variables of other kinds are left out with a warning that names their kinds. - Variables: Save Variable Library… in the Variables panel menu (`variable.saveLibrary`) writes the document's text variables and data sets as a variable library's XML file, which Load Variable Library reads back with the same text variables, data set names and text values. Variables of other kinds are left out with a warning that names their kinds. From 5695823937004fb99c1c0d01dddd2525d2dbb22c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 02:06:18 -0800 Subject: [PATCH 31/89] Export: placed symbol art keeps its instance's stain When PDF, print, EPS and DXF export place a symbol's art to bake its effects, the art now carries the stain the instance's fill gives it, as it did before the art was placed. The art is stained first and its effects apply to the stained colors. --- crates/effects/src/bake.rs | 6 +++--- crates/effects/src/bake/tests.rs | 36 ++++++++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 25 ++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 6e313eb26f..7f7da8e27b 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -210,8 +210,8 @@ fn holds_placed_symbol(n: &Node) -> bool { } /// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it -/// places, as the art it draws: the symbol's art with the instance's own appearance changes -/// ([`vectorcraft_doc::symbol_overrides::dynamic_art`]), placed by the instance as the exporters +/// places, as the art it draws: the symbol's art with the instance's own appearance changes and +/// its stain ([`vectorcraft_doc::symbol_overrides::instance_art`]), placed by the instance as the exporters /// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and /// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an /// instance that scales its art does not scale what its effects do. `None` for other objects, @@ -226,7 +226,7 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { return None; } let sym = d.symbols.iter().find(|s| s.name == *symbol)?; - let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); let most = match d.symbol_slice_map(symbol, *xf) { Some(m) if clip => m.most_placed(&art, d), Some(m) => m.most_placed_unclipped(&art, d), diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index f72637b6f6..be72b4df6e 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1173,6 +1173,42 @@ fn placing_symbols_keeps_dynamic_instances_changes() { assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); } +/// An instance whose fill stains its symbol's art places the art in its stained colors, with the +/// art's effects applied after the stain: a Transform moves the stained square and an Invert +/// inverts the stained color. +#[test] +fn placing_symbols_keeps_the_instances_stain() { + use vectorcraft_geom::Affine; + let blue = Color::rgb8(30, 90, 220); + let mut inst = instance(900, "Mark", Affine::IDENTITY); + inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + inst.appearance.items.retain(|i| i.is_fill()); + if let AppearanceItem::Fill(f) = &mut inst.appearance.items[0] { + f.opacity = 0.5; + } + let square = + Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0)); + let stained = vectorcraft_doc::symbol_overrides::instance_art(&square, &inst).appearance.fill_paint().color().unwrap(); + assert_ne!(stained.to_hex(), blue.to_hex()); + let [r, g, b] = stained.to_rgb(); + let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b); + for (id, params, want) in [("distort.transform", json!({"moveH": 100.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] { + let mut art = square.clone(); + art.appearance.effects.push(fx(id, params)); + let mut d = doc_with(inst.clone()); + add_symbol(&mut d, "Mark", art); + let out = bake_document_placing_symbols(&d, true).unwrap(); + assert!(!is_instance(&out, 900), "{id}: the instance is placed"); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() { + fills.extend(n.appearance.fill_paint().color().map(|c| c.to_hex())); + } + }); + assert_eq!(fills, [want.to_hex()], "{id}"); + } +} + fn is_instance(d: &Document, id: u64) -> bool { d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1b415d3804..5e9796b9b4 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -356,6 +356,31 @@ fn a_stained_instance_keeps_its_stain() { } } +/// A stained instance of a symbol whose art has a live effect is written in its stained colors, +/// where the effect draws the art. +#[test] +fn a_stained_instance_keeps_its_stain_when_its_art_has_effects() { + let blue = Color::rgb8(30, 90, 220); + let square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), blue); + let half = stained_instance("Moved", Affine::IDENTITY, 0.5, true); + let want = vectorcraft_brush::instance_art(&square, &half).appearance.fill_paint().color().unwrap().to_hex(); + assert_ne!(want, blue.to_hex(), "the stain changes the color"); + let mut art = square; + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + let mut d = doc(200.0, 100.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + add(&mut d, half); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); + assert_eq!(leaf_fills(&out), [want]); +} + /// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES` /// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through /// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2c5814996f..a6266f7bb3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1572,7 +1572,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`). The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. From 95450b1e236ce5f9e16ca04e8baa4ea342f973e4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 02:21:31 -0800 Subject: [PATCH 32/89] i18n: Vietnamese translates 115 status and error messages it lacked vi.tsv gets an @msg row for each of the 115 status and error messages that the untranslated report listed as missing from every complete-message catalog and that no open pull request adds to Vietnamese. Among them are the image export, color profile, color book, swatch exchange file, swatch library, DXF and DWG import, 3D and Revolve errors, the zip, regular expression and script file errors, and the `index` whole-number error. Placeholders, backticked names, parameter values and limits stay as the English writes them. Docs: the Vietnamese row of docs/localization-parity.md and a line in docs/target-app-parity.md give the new counts. --- crates/ui-egui/src/i18n/vi.tsv | 117 +++++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 442690385b..05c06346c5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7109,3 +7109,120 @@ breakText Glyphs Glyph Grommets Khoen Grommet Inset Khoảng cách từ mép đến khoen Diameter Đường kính + +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points Hiệu ứng 3D yêu cầu tọa độ hữu hạn trong phạm vi 100 triệu điểm +@msg 3D effects require one to ten lights Hiệu ứng 3D yêu cầu từ một đến mười nguồn sáng +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Độ sâu BMP {_1}: chỉ hỗ trợ 1, 4, 8, 16, 24 hoặc 32 bit mỗi điểm ảnh +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Mã hóa BMP thất bại: {width} × {height} điểm ảnh không vừa với định dạng hoặc bộ đệm điểm ảnh +@msg GIF encoding failed: the palette image is malformed Mã hóa GIF thất bại: ảnh bảng màu bị sai định dạng +@msg GIF encoding failed: {e} Mã hóa GIF thất bại: {e} +@msg JPEG colour profile: {e} Hồ sơ màu JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Mã hóa JPEG thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg JPEG encoding failed: {e} Mã hóa JPEG thất bại: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Mã hóa PNG thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg PNG encoding failed: the palette image is malformed Mã hóa PNG thất bại: ảnh bảng màu bị sai định dạng +@msg PNG encoding failed: {e} Mã hóa PNG thất bại: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Mã hóa PNG thất bại: {width} × {height} điểm ảnh không phải là kích thước ảnh hợp lệ +@msg PSD colour profile: {e} Hồ sơ màu PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Mã hóa PSD thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg Revolve profile contains invalid coordinates Biên dạng Xoay tròn chứa tọa độ không hợp lệ +@msg Revolve profile is too complex Biên dạng Xoay tròn quá phức tạp +@msg Revolve projection is not finite Phép chiếu của Xoay tròn không hữu hạn +@msg Revolve requires finite profile coordinates within 100 million points Xoay tròn yêu cầu tọa độ biên dạng hữu hạn trong phạm vi 100 triệu điểm +@msg Revolve surface is too complex; reduce Segments or simplify the profile Bề mặt Xoay tròn quá phức tạp; hãy giảm Đoạn hoặc đơn giản hóa biên dạng +@msg Revolve surface is too complex; simplify the profile Bề mặt Xoay tròn quá phức tạp; hãy đơn giản hóa biên dạng +@msg Revolve visibility cannot split the projected boundary Tính toán phần nhìn thấy của Xoay tròn không thể tách đường biên đã chiếu +@msg Revolve visibility creates too many fragments Tính toán phần nhìn thấy của Xoay tròn tạo ra quá nhiều mảnh +@msg Revolve visibility creates too many vector anchors Tính toán phần nhìn thấy của Xoay tròn tạo ra quá nhiều điểm neo vectơ +@msg Revolve visibility exceeds its geometry work limit Tính toán phần nhìn thấy của Xoay tròn vượt quá giới hạn khối lượng tính toán hình học +@msg Revolve visibility exceeds its spatial index limit Tính toán phần nhìn thấy của Xoay tròn vượt quá giới hạn chỉ mục không gian +@msg Revolve visibility has too many boundary crossings Tính toán phần nhìn thấy của Xoay tròn có quá nhiều điểm cắt đường biên +@msg Revolve visibility has too many input anchors Tính toán phần nhìn thấy của Xoay tròn có quá nhiều điểm neo đầu vào +@msg Revolve visibility requires convex cap sectors Tính toán phần nhìn thấy của Xoay tròn yêu cầu các hình quạt nắp lồi +@msg Revolve visibility requires finite projected faces Tính toán phần nhìn thấy của Xoay tròn yêu cầu các mặt chiếu hữu hạn +@msg SVG options: {e} Tùy chọn SVG: {e} +@msg Save for Web settings: {e} Cài đặt Lưu cho Web: {e} +@msg TIFF colour profile: {e} Hồ sơ màu TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Mã hóa TIFF thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg TIFF encoding failed: {e} Mã hóa TIFF thất bại: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Độ sâu Targa {_1}: chỉ hỗ trợ 16, 24 hoặc 32 bit mỗi điểm ảnh +@msg Targa encoding failed: the pixel buffer doesn't match the image size Mã hóa Targa thất bại: bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg The 3D projection is not finite Phép chiếu 3D không hữu hạn +@msg The 3D shadow is too complex; turn Shadows off or simplify the art Bóng đổ 3D quá phức tạp; hãy tắt Bóng đổ hoặc đơn giản hóa hình vẽ +@msg The shape contains invalid coordinates Hình dạng chứa tọa độ không hợp lệ +@msg Too many Revolve faces for visibility evaluation Quá nhiều mặt Xoay tròn để tính toán phần nhìn thấy +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft đọc tối đa {ASE_MAX_ENTRIES} màu và nhóm từ một tệp trao đổi mẫu màu, còn thư viện này có {entries} +@msg WebP encoding failed: {e} Mã hóa WebP thất bại: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `index` must be a whole number `index` phải là số nguyên +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` phải là smallThumb, mediumThumb, largeThumb, smallList hoặc largeList +@msg a line can't run from a point to itself một đường không thể đi từ một điểm đến chính nó +@msg a line needs at least two points một đường cần ít nhất hai điểm +@msg a number cannot be read không thể đọc một số +@msg a swatch exchange block is too long một khối trong tệp trao đổi mẫu màu quá dài +@msg a swatch exchange name is too long một tên trong tệp trao đổi mẫu màu quá dài +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels không thể đổi kích thước ảnh {sw} × {sh} thành {width} × {height} điểm ảnh +@msg a {width} × {height} image is too large ảnh {width} × {height} quá lớn +@msg a {width} × {height} image: {e} ảnh {width} × {height}: {e} +@msg an image could not be written: {e} không thể ghi một ảnh: {e} +@msg an image that couldn't be read was left out một ảnh không đọc được đã bị bỏ qua +@msg can't open “{name}”: {e} không thể mở “{name}”: {e} +@msg cannot align zip entries to {align} bytes không thể căn các mục zip theo {align} byte +@msg cannot open the automation {kind} root `{_1}`: {e} không thể mở thư mục gốc {kind} tự động `{_1}`: {e} +@msg cannot read profile: {0} không thể đọc hồ sơ màu: {0} +@msg color book version {version} isn't supported (only 1) không hỗ trợ sổ màu phiên bản {version} (chỉ 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) không hỗ trợ sổ màu trong không gian màu {other} (chỉ RGB, CMYK và Lab) +@msg file name too long to zip: {_1} tên tệp quá dài để nén zip: {_1} +@msg file name too long to zip: {name} tên tệp quá dài để nén zip: {name} +@msg freeform line {i}: {e} đường tự do {i}: {e} +@msg freeform point {i}: {e} điểm tự do {i}: {e} +@msg give `{key}` or `{other}`, not both hãy cung cấp `{key}` hoặc `{other}`, không phải cả hai +@msg grayscale profiles can't be working spaces yet hồ sơ màu thang xám chưa thể dùng làm không gian làm việc +@msg input contains NaN or infinite coordinates đầu vào chứa tọa độ NaN hoặc vô hạn +@msg input path could not be closed không thể đóng đường dẫn đầu vào +@msg invalid ICC profile: {0} hồ sơ ICC không hợp lệ: {0} +@msg invalid scale {scale} tỷ lệ không hợp lệ {scale} +@msg its localized names take more than {_1} KB các tên bản địa hóa của nó chiếm hơn {_1} KB +@msg its objects are nested too deep ({e}) các đối tượng của nó lồng nhau quá sâu ({e}) +@msg its tag table lists the `{_1}` tag more than once bảng thẻ của nó liệt kê thẻ `{_1}` nhiều hơn một lần +@msg not a graphic style library: {e} không phải thư viện kiểu đồ họa: {e} +@msg not a swatch library (.vcswatches or .gpl) không phải thư viện mẫu màu (.vcswatches hoặc .gpl) +@msg not a swatch library (format `{_1}`) không phải thư viện mẫu màu (định dạng `{_1}`) +@msg not a swatch library: {e} không phải thư viện mẫu màu: {e} +@msg not an action set file ({e}) không phải tệp bộ hành động ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray không gian màu {other} của hồ sơ không phải RGB, CMYK hoặc Gray +@msg regular expression too large biểu thức chính quy quá lớn +@msg script file is not UTF-8 text: {value} tệp tập lệnh không phải văn bản UTF-8: {value} +@msg stop {i}: {e} điểm dừng {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) không hỗ trợ tệp trao đổi mẫu màu phiên bản {major}.{minor} (chỉ 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors sổ màu chứa hơn {ASE_MAX_ENTRIES} màu +@msg the color book is cut short sổ màu bị cắt ngắn +@msg the color book's names take more than {_1} MB các tên trong sổ màu chiếm hơn {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution ảnh sẽ có kích thước {w} × {h} điểm ảnh; xuất raster bị giới hạn ở {MAX_RASTER_SIDE} điểm ảnh mỗi cạnh và {_1} megapixel: hãy giảm tỷ lệ hoặc độ phân giải +@msg the optimised image can't be read back: {e} không thể đọc lại ảnh đã tối ưu hóa: {e} +@msg the pixel buffer doesn't match the image size bộ đệm điểm ảnh không khớp với kích thước ảnh +@msg the preview: {e} bản xem trước: {e} +@msg the request may not read the New Document profiles folder yêu cầu không được phép đọc thư mục hồ sơ Tài liệu mới +@msg the shapes are too degenerate to combine các hình dạng quá suy biến để kết hợp +@msg the shapes cross themselves and each other too often to combine các hình dạng tự cắt và cắt nhau quá nhiều lần để kết hợp +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups tệp trao đổi mẫu màu chứa hơn {ASE_MAX_ENTRIES} màu và nhóm +@msg the swatch exchange file is cut short tệp trao đổi mẫu màu bị cắt ngắn +@msg the {_1} plate can't be read: {e} không thể đọc bản in {_1}: {e} +@msg this binary DXF file is damaged or cut short tệp DXF nhị phân này bị hỏng hoặc bị cắt ngắn +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that đây là bản vẽ DWG, không thể đọc được: hãy lưu nó dưới dạng DXF từ ứng dụng CAD rồi mở tệp đó +@msg too many classes quá nhiều lớp ký tự +@msg too many groups quá nhiều nhóm +@msg too many layers for PSD (at most 32767) quá nhiều lớp cho PSD (tối đa 32767) +@msg too many swatch exchange blocks quá nhiều khối trong tệp trao đổi mẫu màu +@msg unknown colour profile: {0} hồ sơ màu không xác định: {0} +@msg {_1} clashes with a built-in profile name {_1} trùng với tên hồ sơ màu có sẵn +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} điểm ảnh quá lớn cho GIF (tối đa 65535 điểm ảnh mỗi cạnh): hãy giảm độ phân giải +@msg {_1}: no usable transform to/from sRGB {_1}: không có phép chuyển đổi dùng được sang/từ sRGB +@msg {name} is not a CMYK profile {name} không phải hồ sơ màu CMYK +@msg {name} is not an RGB profile {name} không phải hồ sơ màu RGB +@msg {path}: could not be read {path}: không thể đọc +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} điểm ảnh quá lớn cho JPEG (tối đa 65535 điểm ảnh mỗi cạnh): hãy giảm độ phân giải +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} điểm ảnh quá lớn cho Targa (tối đa 65535 điểm ảnh mỗi cạnh): hãy giảm độ phân giải +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} điểm ảnh quá lớn cho PSD (tối đa {MAX_SIDE} điểm ảnh mỗi cạnh): hãy giảm độ phân giải diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9080695a8e..a79481f421 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -40,7 +40,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,433 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 48 missing messages for it) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all (3,438 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 39 missing messages for it) | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | -| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | +| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all (1,746 `@msg` and 48 `@msgplural` rows, counted 2026-11-14: the untranslated report lists 1,868 messages without a Vietnamese row, 53 of which open #7211, #7378, #7405 and #7434 translate) | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | **Historical 12-language score: ~82%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 100 + 100 + 100 + 98 + 100 + 100, over 12). **To `full` for all twelve: ~50–85 h** (historical estimate, not re-scored here). Hindi now has diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..458cfc3fcf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -915,6 +915,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - The German catalog (`crates/ui-egui/src/i18n/de.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,433 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 48 German messages; 38 of them are the composite font, select-a-path and justification order errors that open #7211, #7214 and #7116 translate. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The Korean catalog (`crates/ui-egui/src/i18n/ko.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,438 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 39 Korean messages; they are the composite font, select-a-path, justification order and `path.freehand` connect errors that open #7211, #7214, #7116 and #7330 translate. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. + - The Vietnamese catalog (`crates/ui-egui/src/i18n/vi.tsv`) has an `@msg` row for 115 of the status and error messages that the untranslated report listed for every complete-message catalog, among them the image export, color profile, color book, swatch exchange file, swatch library, DXF and DWG import, 3D and Revolve errors and the `index` whole-number error, 1,746 `@msg` rows in all. The untranslated report (`untranslated_report`) lists 1,868 Vietnamese messages without a row; open #7211, #7378, #7405 and #7434 translate 53 of them. - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. - The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are. From 1c973dc14170d2d3daacf626439dc75f4a307016 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 02:36:33 -0800 Subject: [PATCH 33/89] SVG import: absolutely placed characters in right-to-left text land on their x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An absolutely placed character starts its own text chunk, anchored on its own x (SVG 1.1 text chunks). In a right-to-left line the import kerned the character before each placed one as if kerning moved the placed character right, but a right-to-left glyph takes its kerning at its left, so the correction moved the wrong glyph. In <text direction="rtl" font-size="20">, <tspan x="100 160" y="80 80">אב</tspan> on a second line put the right edge of א at 393.48 instead of 100. The placement now works in drawing order: each kerning is a cut after its character (before it when it runs right to left), each gap between two placed neighbors takes one cut, and the line's left edge is measured again every round. Corrections are divided by the character's horizontal scale, so text stretched with lengthAdjust="spacingAndGlyphs" lands on its x too. A left-to-right character drawn between two placed right-to-left ones has no kerning slot before the next one, which then keeps its place in the line; the import warns about it. --- crates/svg/src/import/text.rs | 166 +++++++++++++++++++++++------ crates/svg/tests/it/import_text.rs | 110 ++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 241 insertions(+), 36 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 1a221a69f1..6b1ce0c063 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -40,7 +40,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, ParaStyle, StrokeLayer, TextKind, TextObject, TextRun}; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; -use vectorcraft_text::{FontDb, TextLayout}; +use vectorcraft_text::{FontDb, PositionedGlyph, TextLayout, spacing_scale}; use super::css::{Styles, XNode}; use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href, vector_effect}; @@ -1435,7 +1435,12 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks); + let (justify, left, starts, unplaced) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks); + if unplaced > 0 { + warnings.push(format!( + "{label}: {unplaced} absolutely placed character(s) between characters of the other direction keep their place in the line" + )); + } obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); // Lines anchored differently or at different x (paragraphs aligned or indented @@ -1568,7 +1573,8 @@ fn leading_tolerance(spacing: f64) -> f64 { /// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk /// starting at a character sits on its anchor, and `pin` sets the paragraph directions of the /// text ([`pin_direction`]). When characters are placed on their own, every line is left-aligned -/// and its anchor x is its left edge. +/// and its anchor x is its left edge. Also returns how many absolutely placed characters kerning +/// cannot put on their x. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1580,7 +1586,7 @@ fn lines( pin: &dyn Fn(&mut TextObject), x0: f64, breaks: &mut Vec<Break>, -) -> (Justify, f64, Vec<(usize, f64, f64)>) { +) -> (Justify, f64, Vec<(usize, f64, f64)>, usize) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -1623,7 +1629,7 @@ fn lines( } if targets.is_empty() { let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect(); - return (justify_of(anchor_of(0)), x0, starts); + return (justify_of(anchor_of(0)), x0, starts, 0); } // Absolutely placed characters: lay the text out left-aligned and kern each one onto its x. // SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left @@ -1633,59 +1639,122 @@ fn lines( // Chunk starts in order: line starts and absolutely placed characters. let mut starts: Vec<usize> = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect(); starts.sort_unstable(); - // The left and right edges of the chunk starting at character `s`, from each character's pen and end. - let edges = |s: usize, pens: &[Option<f64>], ends: &[Option<f64>]| { + // The left and right edges of the chunk starting at character `s`, and the character drawn at its left. + let edges = |s: usize, pens: &[Option<Pen>]| { let e = starts.get(starts.partition_point(|&c| c <= s)).copied().unwrap_or(n); - let left = (s..e).filter_map(|j| pens[j]).fold(f64::INFINITY, f64::min); - let right = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max); - (left.is_finite() && right.is_finite()).then_some((left, right)) + let (mut left, mut right, mut at) = (f64::INFINITY, f64::NEG_INFINITY, s); + for (j, p) in pens.iter().enumerate().take(e).skip(s) { + let Some(p) = p else { continue }; + if p.start < left { + (left, at) = (p.start, j); + } + right = right.max(p.end); + } + (left.is_finite() && right.is_finite()).then_some((left, right, at)) }; let mut line_left: Vec<f64> = lines.iter().map(|l| l.1).collect(); + // Where each line's first chunk's left edge goes. + let mut first_left: Vec<f64> = vec![]; // Where each absolutely placed character's pen goes, from the left edge of its line's first chunk. let mut wants: Vec<(usize, f64)> = vec![]; + // Absolutely placed characters drawn right next to a placed neighbor with no kerning between + // them (a left-to-right character between right-to-left ones): they stay where the layout puts them. + let mut unplaced = 0; // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. for round in 0..4 { let (runs, bytes) = assemble(cells, breaks); let mut text = with_runs(runs.0); pin(&mut text); - let (pens, ends) = pens(&measure(&text), &bytes); + let pens = pens(&measure(&text), &bytes, cells); // The edges of each line's first chunk, found once a round. - let first: Vec<Option<(f64, f64)>> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect(); + let first: Vec<Option<(f64, f64, usize)>> = lines.iter().map(|&(s, _)| edges(s, &pens)).collect(); if round == 0 { // The chunks as they lie before any is moved: each one's left edge on its anchor. - let left_at = |s: usize, x: f64, chunk: Option<(f64, f64)>| chunk.map_or(x, |(left, right)| x - anchor_of(s) * (right - left)); - let first_left: Vec<f64> = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect(); + let left_at = |s: usize, x: f64, chunk: Option<(f64, f64, usize)>| chunk.map_or(x, |(left, right, _)| x - anchor_of(s) * (right - left)); + first_left = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect(); wants = targets .iter() .map(|&(i, x)| { - let chunk = edges(i, &pens, &ends); + let chunk = edges(i, &pens); // The pen of a right-to-left chunk's first character is right of the chunk's left edge. - let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, _))| p - left); + let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, ..))| p.start - left); (i, left_at(i, x, chunk) + pen - first_left[line_of(i)]) }) .collect(); - // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` from there. - line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, _)| left)).collect(); } - let (mut line, mut moved, mut worst) = (usize::MAX, 0.0, 0.0f64); - for &(i, want) in &wants { - let l = line_of(i); - if l != line { - (line, moved) = (l, 0.0); + // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` + // from there (right-to-left kerning can move that chunk). + line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, ..)| left)).collect(); + // Kerning a character widens its advance to the right: the glyphs drawn right of it move + // right, and so does a right-to-left glyph's own left edge (its kerning is at its left). In + // drawing order the kerning before an absolutely placed character is a cut after the + // character before it (before that character when it runs right to left), and what is drawn + // after the cut moves. Places in drawing order are doubled (from 2) to put cuts between them. + // Each gap between two placed characters drawn one after the other takes the kerning of one + // cut in it, so placing a character moves none placed before it. Kerning is drawn at the + // character's horizontal scale, so each correction is divided by it. + let mut worst = 0.0f64; + let mut from = 0; + unplaced = 0; + while from < wants.len() { + let l = line_of(wants[from].0); + let to = from + wants[from..].partition_point(|&(i, _)| line_of(i) == l); + let on_line = &wants[from..to]; + from = to; + let Some((p0, _, at)) = first[l] else { continue }; + let Some(home) = pens[at].map(|p| 2 * p.rank + 2) else { continue }; + // A character drawn as part of the glyph before it (a mark) is kerned where that glyph is. + // The kerning stays on the character just before the placed one: a mark whose kerning + // differs from its base's is drawn as a glyph of its own, and kerning the base would put + // the space between the base and its mark. + // Each search stops at the nearest earlier character with a glyph, at the latest the + // placed character before it, so the searches of a line read each character once. + let mut cuts: Vec<(usize, usize)> = on_line + .iter() + .filter(|&&(i, _)| pens[i].is_some()) + .filter_map(|&(i, _)| (lines[l].0..i).rev().find_map(|j| pens[j]).map(|k| (2 * k.rank + if k.rtl { 1 } else { 3 }, i - 1))) + .collect(); + cuts.sort_unstable(); + let mut points: Vec<(usize, f64, f64)> = + on_line.iter().filter_map(|&(i, want)| pens[i].map(|p| (2 * p.rank + 2, p.start, want))).collect(); + points.sort_by_key(|p| p.0); + // The character kerned at the first cut between places `a` and `b`. + let cut = |a: usize, b: usize| cuts.get(cuts.partition_point(|c| c.0 <= a)).filter(|c| c.0 < b).map(|c| c.1); + let split = points.partition_point(|p| p.0 < home); + // Drawn right of the line's first chunk: kerning moves the character away from it. + let (mut prev, mut moved) = (home, 0.0); + for &(place, start, want) in &points[split..] { + if let Some(k) = cut(prev, place) { + let d = want - (start - p0 + moved); + cells[k].kern += d / spacing_scale(&cells[k].style, false); + moved += d; + worst = worst.max(d.abs()); + } else { + unplaced += 1; + } + prev = place; + } + // Drawn left of it: kerning moves the chunk (and what lies between) away from the character. + (prev, moved) = (home, 0.0); + for &(place, start, want) in points[..split].iter().rev() { + if let Some(k) = cut(place, prev) { + let d = start - p0 - moved - want; + cells[k].kern += d / spacing_scale(&cells[k].style, false); + moved += d; + worst = worst.max(d.abs()); + } else { + unplaced += 1; + } + prev = place; } - let (Some(p), Some((p0, _))) = (pens[i], first[l]) else { continue }; - let d = want - (p - p0 + moved); - cells[i - 1].kern += d; - moved += d; - worst = worst.max(d.abs()); } if worst < 0.01 { break; } } let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect(); - (Justify::Left, line_left[0], starts) + (Justify::Left, line_left[0], starts, unplaced) } /// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` @@ -1794,13 +1863,40 @@ fn advance(t: &TextObject) -> f64 { measure(t).glyphs.iter().map(|g| g.advance).sum() } -/// Pen x (start) and end x of the glyph that starts at each character's byte offset. -fn pens(lay: &TextLayout, bytes: &[usize]) -> (Vec<Option<f64>>, Vec<Option<f64>>) { - let mut at: HashMap<usize, (f64, f64)> = HashMap::with_capacity(lay.glyphs.len()); - for g in &lay.glyphs { - at.entry(g.byte).or_insert((g.origin.x, g.origin.x + g.advance)); +/// Where the glyph that starts a character is drawn on a line of point type. +#[derive(Clone, Copy)] +struct Pen { + /// Its left edge. Manual kerning on a right-to-left glyph is at its left, and the glyph is + /// drawn right of it: its left edge is past that kerning. + start: f64, + /// The right edge of its advance. + end: f64, + /// Its place in the order its line is drawn in, from the left. + rank: usize, + rtl: bool, +} + +/// The [`Pen`] of the glyph that starts at each character's byte offset (`bytes`, parallel to `cells`). +fn pens(lay: &TextLayout, bytes: &[usize], cells: &[Cell]) -> Vec<Option<Pen>> { + // A line's glyphs are laid out in the order they are drawn. + let mut at: HashMap<usize, (usize, &PositionedGlyph)> = HashMap::with_capacity(lay.glyphs.len()); + for (rank, g) in lay.glyphs.iter().enumerate() { + at.entry(g.byte).or_insert((rank, g)); } - bytes.iter().map(|b| at.get(b).map_or((None, None), |&(s, e)| (Some(s), Some(e)))).unzip() + bytes + .iter() + .zip(cells) + .map(|(b, c)| { + let &(rank, g) = at.get(b)?; + let mut start = g.origin.x; + if g.rtl { + let st = c.final_style(); + let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(&st, false); + start += finite(kern); + } + Some(Pen { start, end: g.origin.x + g.advance, rank, rtl: g.rtl }) + }) + .collect() } type Key = (Option<usize>, Option<usize>); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 472d7517e4..b4cc379506 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{GradientKind, Paint}; use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_svg::{import, import_with_report}; -use vectorcraft_text::{FontDb, layout}; +use vectorcraft_text::{FontDb, layout, spacing_scale}; fn svg(body: &str) -> String { format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">{body}</svg>"#) @@ -719,6 +719,114 @@ fn absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edg } } +/// Document-space left and right edges of the natural advance of the characters in `bytes` (the +/// manual kerning that places the next chunk left out: it is at a glyph's right when the glyph runs +/// left to right and at its left when it runs right to left). +fn chunk_edges(t: &TextObject, bytes: std::ops::Range<usize>) -> (f64, f64) { + let lay = layout(FontDb::global(), t); + let mut starts = vec![]; + let mut at = 0; + for r in &t.runs { + starts.push((at, &r.style)); + at += r.text.len(); + } + let style = |byte: usize| starts.iter().rev().find(|(s, _)| *s <= byte).map(|(_, st)| *st).unwrap(); + let (mut left, mut right) = (f64::INFINITY, f64::NEG_INFINITY); + for g in lay.glyphs.iter().filter(|g| bytes.contains(&g.byte)) { + let st = style(g.byte); + let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(st, false); + let x = (t.xf * g.origin).x; + let (l, r) = if g.rtl { (x + kern, x + g.advance) } else { (x, x + g.advance - kern) }; + (left, right) = (left.min(l), right.max(r)); + } + assert!(left.is_finite(), "no glyph in {bytes:?}"); + (left, right) +} + +/// SVG 1.1 §10.4 and SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): +/// every absolutely placed character starts a chunk anchored on its own x, also in right-to-left +/// text whose characters run right to left, on any line. +#[test] +fn absolutely_placed_right_to_left_characters_sit_on_their_own_x() { + // (markup, share of each chunk's advance left of its x, each chunk's bytes and x) + let two_lines = |anchor: &str| { + format!( + r#"<text direction="rtl" text-anchor="{anchor}" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100 160" y="80 80">אב</tspan></text>"# + ) + }; + type Chunks = Vec<(std::ops::Range<usize>, f64)>; + let cases: Vec<(String, f64, Chunks)> = vec![ + (two_lines("start"), 1.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (two_lines("middle"), 0.5, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (two_lines("end"), 0.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (r#"<text x="100 160" y="80" direction="rtl" font-size="20">אב</text>"#.into(), 1.0, vec![(0..2, 100.0), (2..4, 160.0)]), + (r#"<text x="100 160" y="80" direction="rtl" text-anchor="end" font-size="20">אב</text>"#.into(), 0.0, vec![(0..2, 100.0), (2..4, 160.0)]), + // Three lines, the characters of the last out of order and overlapping. + ( + r#"<text direction="rtl" font-size="20"><tspan x="300" y="40">אב</tspan><tspan x="60 120" y="80">גד</tspan><tspan x="200 90 205" y="120">הוז</tspan></text>"#.into(), + 1.0, + vec![(0..4, 300.0), (5..7, 60.0), (7..9, 120.0), (10..12, 200.0), (12..14, 90.0), (14..16, 205.0)], + ), + // Left-to-right characters among right-to-left ones on the same line. + (r#"<text x="100 160 220" y="80" direction="rtl" font-size="20">אbc</text>"#.into(), 1.0, vec![(0..2, 100.0), (2..3, 160.0), (3..4, 220.0)]), + // Chunks of several characters. + ( + r#"<text direction="rtl" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100" y="80">אב</tspan><tspan x="250">גד</tspan></text>"#.into(), + 1.0, + vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)], + ), + ( + r#"<text direction="rtl" text-anchor="middle" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100" y="80">אב</tspan><tspan x="250">גד</tspan></text>"#.into(), + 0.5, + vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)], + ), + // A placed character after a letter with a combining mark. + ( + r#"<text direction="rtl" font-size="20"><tspan x="100" y="80">אָ</tspan><tspan x="160">ב</tspan></text>"#.into(), + 1.0, + vec![(4..6, 160.0)], + ), + (r#"<text font-size="20"><tspan x="100" y="80">é</tspan><tspan x="160">b</tspan></text>"#.into(), 0.0, vec![(3..4, 160.0)]), + // Characters stretched by `lengthAdjust`, whose kerning is drawn at their scale. + ( + r#"<text direction="rtl" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100 160" y="80 80" textLength="12" lengthAdjust="spacingAndGlyphs">אב</tspan></text>"#.into(), + 1.0, + vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)], + ), + // Left to right, the right-to-left characters still each start at their own x. + ( + r#"<text direction="ltr" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100 160" y="80 80">אב</tspan></text>"#.into(), + 0.0, + vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)], + ), + ( + r#"<text font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100 160" y="80 80">cd</tspan></text>"#.into(), + 0.0, + vec![(0..2, 20.0), (3..4, 100.0), (4..5, 160.0)], + ), + ]; + let unplaced = |w: &[String]| w.iter().any(|w| w.contains("absolutely placed")); + for (body, share, chunks) in cases { + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(!unplaced(&w), "{body}: {w:?}"); + let t = only_text(&d); + if body.contains("lengthAdjust") { + assert!(t.runs.iter().any(|r| r.style.h_scale < 99.0), "{body}: {:?}", t.runs); + } + for (bytes, x) in chunks { + let (left, right) = chunk_edges(t, bytes.clone()); + let want = x - share * (right - left); + assert!(close(left, want, 0.05), "{body}: the chunk {bytes:?} spans {left}..{right}, so it does not start at {want}"); + } + } + // A left-to-right character between right-to-left ones is drawn right next to the one after + // it, with no kerning between them: that one keeps its place in the line, and a warning says so. + let (d, w) = import_with_report(&svg(r#"<text x="100 160 220" y="80" direction="rtl" font-size="20">אbג</text>"#)).unwrap(); + assert!(unplaced(&w), "{w:?}"); + let (left, right) = chunk_edges(only_text(&d), 2..3); + assert!(close(right, 160.0, 0.05), "b spans {left}..{right}"); +} + #[test] fn a_small_line_gap_in_tiny_type_keeps_its_leading() { // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..fd11033247 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1658,6 +1658,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623). SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`). SVG import places right-to-left point type and type on a path as [SVG 2's `text-anchor`](https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties) says: with `direction: rtl`, `start` puts the right edge of the text at its position (on a path, the text ends at `startOffset`) and `end` puts the left edge there. Each line and each absolutely placed chunk is anchored by the `text-anchor` and `direction` of the element holding its first character, a `<tspan>`'s own included, and each line starts at its own `x`. An absolutely placed chunk is anchored by the left and right edges of its glyphs as they are drawn, so a chunk of several right-to-left characters, whose first character is drawn at its right, sits on its anchor too. A letter-spaced right-to-left line keeps the tracking of all its letters inside the advance it anchors (`rtl_point_text_start_and_end_use_directional_edges`, `rtl_type_on_a_path_starts_at_its_directional_edge`, `rtl_letter_spaced_lines_stay_on_their_anchor`, `rtl_lines_use_each_chunk_first_character_direction_and_anchor`, `each_line_starts_at_its_own_x`, `absolutely_placed_chunks_use_their_own_rtl_anchor` and `absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edges` in `crates/svg/tests/it/import_text.rs`). + SVG import puts each absolutely placed character of right-to-left text on its own `x`, as [SVG 1.1's text chunks](https://www.w3.org/TR/SVG11/text.html#TextLayoutIntroduction) require, also when the characters run right to left on a later line: in `<text direction="rtl" font-size="20">`, `<tspan x="100 160" y="80 80">אב</tspan>` after a first line puts the right edges of `א` and `ב` at 100 and 160, where import put the right edge of `א` at 393.48 before. Chunks that overlap keep their own anchors, and characters stretched by `lengthAdjust="spacingAndGlyphs"` land on their `x` too. A left-to-right character drawn between two absolutely placed right-to-left characters leaves the one after it where the line puts it, and the import warns about it (`absolutely_placed_right_to_left_characters_sit_on_their_own_x` in `crates/svg/tests/it/import_text.rs`). SVG import reads the bidi embedding of a `<tspan>`, `<textPath>` or `<a>` inside a `<text>` as [SVG 1.1's bidi section](https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality) describes: with `unicode-bidi: embed` its characters take its `direction` as their Character Direction, an element with `normal` stays in its parent's embedding, and the `<text>` keeps its own paragraph direction, so a run that SVG export writes with `direction` and `unicode-bidi="embed"` opens with its Character Direction again. Character Direction holds one level of embedding, so `bidi-override`, `isolate` and `isolate-override` are imported as embeddings in their `direction`, `plaintext` as an embedding in the direction of its first strong character, and an embedding inside another as an embedding of its own, each with an import warning. With `unicode-bidi: plaintext` on the `<text>`, each of its paragraphs runs, and each chunk is anchored, in the direction of its first strong character (`a_tspan_bidi_embedding_becomes_its_character_direction`, `unicode_bidi_is_not_inherited_and_reads_from_the_cascade` and `unicode_bidi_values_the_text_model_cannot_hold_are_reported` in `crates/svg/tests/it/import_text.rs`; `character_direction_reads_back` in `crates/svg/tests/it/text_layout.rs`). A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does. SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports. From 3824b15a77144925b96b6372d7a5f8bffc2139cf Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 22:44:06 -0800 Subject: [PATCH 34/89] .ai and EPS import: large canvas symbols come in at the file's size VectorCraft reads the symbol definitions of a large canvas .ai or EPS, and of a .ai whose page is in larger units, in the same units as its layers. Opening such a file scaled the layers and graphic styles up to full size but left the symbols' art as it was. Instances on the layers drew at the right size, but each one's transform carried the factor N, and a newly placed instance came in at 1/N of the size, where N is the file's large canvas scale or its page's unit. - Opening the file now scales each symbol's art, strokes and effects with the layers. Instances on the layers, in symbol art, in pattern tiles and in opacity masks keep their own scale and rotation and draw where they did. - A symbol library without layers from such a file gets the same scale as its graphic styles. - Opacity masks on the layers that do not move with their object are scaled with the layers too, as are those in symbol art. - Editing data that VectorCraft wrote (its %%Creator line) keeps its symbols and those masks at their own size, as the .ai save writes them, so files saved by earlier versions open as before. --- crates/eps/src/ai_editing.rs | 5 +- crates/eps/src/import.rs | 2 +- crates/eps/src/import/ai/read.rs | 6 + crates/eps/src/import/ai/read/directives.rs | 4 + crates/eps/src/import/ai/read/document.rs | 12 +- crates/eps/src/import/native.rs | 2 +- crates/eps/src/import/native/build.rs | 192 +++++++++++++++++++- crates/eps/src/import/native/layers.rs | 81 ++++++--- crates/eps/src/import/tests_native.rs | 126 +++++++++++++ docs/target-app-parity.md | 1 + 10 files changed, 390 insertions(+), 41 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 0048cb945f..3df902b97f 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -228,8 +228,9 @@ const GRID_GRAY: f64 = 200.0 / 255.0; /// `%%Creator`: the creator line VectorCraft writes in the header of the editing data. It names /// VectorCraft and the format's app, and holds no version number; the version is written on its -/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). -const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes +/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). The reader reads the symbols of data with +/// this creator at their own size, as [`export_ai_editing`] writes them on a large canvas. +pub(crate) const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes /// The first line of a PostScript file (the DSC version comment). const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index bdb3b9cf50..c791f7c459 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -94,7 +94,7 @@ pub(crate) use ai::{ }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; -pub(crate) use native::{MAX_TEXT, scale_document}; +pub(crate) use native::{MAX_TEXT, scale_document, scale_symbols}; pub use text::family_style; pub(crate) use text::{document_font, postscript_face}; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 9d66bd2d17..59aaf3df92 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -215,6 +215,9 @@ pub struct Structure { pub text_patterns: HashMap<String, Paint>, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, + /// The data's symbols are at their own size, whatever its scale: VectorCraft wrote it (its + /// `%%Creator` is [`crate::ai_editing::CREATOR_TEXT`]). + pub symbols_own_size: bool, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an /// id two objects have. pub xml_ids: HashMap<String, Option<NodeId>>, @@ -644,6 +647,8 @@ struct Reader<'a> { page_art: bool, /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. canvas_scale: f64, + /// [`Structure::symbols_own_size`], from the header's `%%Creator`. + symbols_own_size: bool, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). ruler_units: Option<u8>, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). @@ -818,6 +823,7 @@ impl<'a> Reader<'a> { large_canvas: None, page_art: false, canvas_scale: 1.0, + symbols_own_size: false, ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index f86ee6603b..0fe6b9da87 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -79,6 +79,10 @@ impl<'a> Reader<'a> { } return Ok(()); } + if let Some(v) = bytes.strip_prefix(b"%Creator:") { + self.symbols_own_size = v.trim_ascii() == crate::ai_editing::CREATOR_TEXT.as_bytes(); + return Ok(()); + } if bytes == b"%EndComments" { self.header_done = true; } diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index a7451c8b99..3810625b0d 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -467,6 +467,10 @@ pub struct Symbols { /// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds /// them to a document read from the layers. pub swatches: Vec<Swatch>, + /// The data's declared large canvas scale (1 when it declares none), not applied to the art. + pub canvas_scale: f64, + /// [`Structure::symbols_own_size`]. + pub own_size: bool, } /// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library @@ -529,7 +533,7 @@ pub fn symbols(data: &[u8], names: &mut obj::Names) -> Option<Symbols> { .chain(art_names(symbols.iter().map(|(s, _)| &*s.art))) .collect(); names.absorb_among(std::mem::take(&mut r.names), &used); - Some(Symbols { symbols, images, swatches }) + Some(Symbols { symbols, images, swatches, canvas_scale: r.canvas_scale, own_size: r.symbols_own_size }) } /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and @@ -1194,6 +1198,11 @@ impl<'a> Reader<'a> { drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); + // A Large Canvas copy's symbols are at its art's scale too ([`Self::ensure_space`]), except + // in data VectorCraft wrote. + if let Some(s) = self.large_canvas.filter(|s| *s != 1.0 && !self.symbols_own_size) { + crate::import::scale_symbols(&mut doc, s); + } // The active artboard, when it is not the first (the default). let active = self.active_board.filter(|i| *i > 0 && *i < self.artboards.len()); let boards: Vec<AiArtboard> = if self.artboards.is_empty() { @@ -1374,6 +1383,7 @@ impl<'a> Reader<'a> { styles_read, text_patterns, canvas_scale: self.canvas_scale, + symbols_own_size: self.symbols_own_size, xml_ids: self.xml_ids, bindings, bindings_skipped, diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index eb5e815e80..5acaba34a4 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -47,9 +47,9 @@ mod layers; mod plan; mod slots; -pub(crate) use build::scale_document; #[cfg(test)] use build::{Frame, MADE, prune_unseen}; +pub(crate) use build::{scale_document, scale_symbols}; #[cfg(test)] use compare::{ MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 1b212194a1..c469f02cd4 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -10,7 +10,7 @@ use super::slots::{ use super::{ Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, VariableKind, Vec2, ai, }; -use vectorcraft_doc::PlacedDocument; +use vectorcraft_doc::{PlacedDocument, Symbol}; /// Which box of the editing copy is the page. #[derive(Clone, Copy)] @@ -83,12 +83,11 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64, names: ai: }; let in_units = |r: Option<Rect>, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0); let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k))); - let styles = structure.styles_read; + let (styles, own_size) = (structure.styles_read, structure.symbols_own_size); let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; if let Some(k) = scale { // The graphic styles read from the file are drawn in its units too. - scale_document(&mut doc, k, styles); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, k, styles, own_size); } Ok((doc, notes)) } @@ -108,12 +107,11 @@ pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f6 let Some(scale) = alone_scale(&structure, data, pages, unit) else { return Err(Box::new(structure)); }; - let styles = structure.styles_read; + let (styles, own_size) = (structure.styles_read, structure.symbols_own_size); let built = build_from(structure, data, None, Page::Artboard, false, false, 1.0).map(|(mut doc, notes)| { if scale != 1.0 { // The graphic styles read from the file are drawn in its units too. - scale_document(&mut doc, scale, styles); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, scale, styles, own_size); } (doc, notes) }); @@ -268,6 +266,7 @@ pub(super) fn build_weighing( styles_read, text_patterns, canvas_scale, + symbols_own_size, xml_ids, bindings, bindings_skipped, @@ -577,8 +576,7 @@ pub(super) fn build_weighing( bind_variables(&mut doc, &bindings, bindings_skipped, &xml_ids, &filled, &mut notes); placed_documents(&mut doc, &placed_pages); if small.is_some() { - scale_document(&mut doc, unit, styles_read); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, unit, styles_read, symbols_own_size); } Ok((doc, notes)) } @@ -1916,7 +1914,7 @@ fn drop_guides_off_boards(doc: &mut Document) { /// styles. pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let a = Affine::scale(k); - let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; + let scaling = document_scaling(); for layer in &mut doc.layers { let layer = Arc::make_mut(layer); layer.transform(a, scaling); @@ -1935,6 +1933,101 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { } } +/// What [`scale_document`] and [`scale_symbols`] scale with the art: strokes, effects and pattern +/// tiles. +fn document_scaling() -> Scaling { + Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() } +} + +/// A document read in units of `1/k` points drawn at its size: [`scale_document`], and its wrapped +/// type laid out again. Unless `own_size` (data VectorCraft wrote, whose symbols and opacity masks +/// that don't move with their object are at their own size), also the art of those masks on the +/// layers and its symbols ([`scale_symbols`]). +fn scale_read_document(doc: &mut Document, k: f64, styles: bool, own_size: bool) { + scale_document(doc, k, styles); + if !own_size { + let a = Affine::scale(k); + for layer in &mut doc.layers { + transform_unlinked_masks(Arc::make_mut(layer), a, document_scaling()); + } + scale_symbols(doc, k); + } + relayout_wrapped(doc); +} + +/// Draw the art of `doc`'s symbols `k` times as large ([`scale_symbol_art`]), with every instance +/// on the layers and in a pattern's tile (opacity masks included) placed by its transform times +/// `1/k`, so it draws where and as large as before, with the symbol's strokes and effects `k` +/// times as wide. +pub(crate) fn scale_symbols(doc: &mut Document, k: f64) { + if !(k.is_finite() && k > 0.0) || k == 1.0 || doc.symbols.is_empty() { + return; + } + scale_symbol_art(&mut doc.symbols, k); + let back = Affine::scale(1.0 / k); + for n in doc.layers.iter_mut().chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { + place_instances_by(n, back); + } +} + +/// Draw the art of `symbols` `k` times as large, from each symbol's origin, with its strokes, +/// effects and pattern tiles, as [`scale_document`] draws the layers. An instance in the art keeps +/// its own scale and turn: it is placed by its transform times `1/k`. +pub(crate) fn scale_symbol_art(symbols: &mut [Symbol], k: f64) { + if !(k.is_finite() && k > 0.0) || k == 1.0 { + return; + } + let (a, back) = (Affine::scale(k), Affine::scale(1.0 / k)); + for s in symbols { + let art = Arc::make_mut(&mut s.art); + art.transform(a, document_scaling()); + transform_unlinked_masks(art, a, document_scaling()); + scale_wrap_offsets(art, k); + place_instances_by(&mut s.art, back); + } +} + +/// The art of the opacity masks in `n` that don't move with their object, which +/// [`Node::transform`] leaves where it is, transformed by `a` with `scaling` as `n` was: a change of +/// units moves them too. +fn transform_unlinked_masks(n: &mut Node, a: Affine, scaling: Scaling) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + let linked = m.linked; + let art = Arc::make_mut(&mut m.art); + if !linked { + art.transform(a, scaling); + } + transform_unlinked_masks(art, a, scaling); + } + if let Some(children) = n.children_mut() { + for c in children { + transform_unlinked_masks(Arc::make_mut(c), a, scaling); + } + } +} + +/// Every symbol instance in `n` (opacity masks included) placed by its transform times `m`. Only +/// the objects on the way to an instance are copied. +fn place_instances_by(n: &mut Arc<Node>, m: Affine) { + let mut holds = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. })); + if !holds { + return; + } + let n = Arc::make_mut(n); + if let NodeKind::SymbolInstance { xf, .. } = &mut n.kind { + *xf *= m; + } + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + place_instances_by(&mut mask.art, m); + } + if let Some(children) = n.children_mut() { + for c in children { + place_instances_by(c, m); + } + } +} + /// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points /// they may be). fn scale_wrap_offsets(n: &mut Node, k: f64) { @@ -2256,6 +2349,85 @@ mod candidate_tests { assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]); } + /// Symbols drawn ten times as large keep every instance where and as large as it was (on a layer, + /// in an opacity mask's art, linked or not, and in a pattern's tile), and an instance in a + /// symbol's art keeps its own scale and turn, moved with the art. + #[test] + fn scaling_the_symbols_keeps_their_instances_in_place() { + let mut doc = Document::new(100.0, 100.0); + let boxed = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-1.0, -0.5, 1.0, 0.5)), Default::default()); + doc.symbols.push(Symbol { name: "Box".into(), art: Arc::new(boxed), dynamic: false }); + let turn = Affine::translate((3.0, 4.0)) * Affine::rotate(0.5) * Affine::scale(2.0); + let nested = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn }); + let mut pair = Node::group(doc.alloc_id(), vec![Arc::new(nested)]); + // An opacity mask that doesn't move with its object, holding an instance too. + let masking = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn }); + let mut unlinked = vectorcraft_doc::OpacityMask::new(masking, true); + unlinked.linked = false; + pair.extras_mut().mask = Some(Box::new(unlinked)); + doc.symbols.push(Symbol { name: "Pair".into(), art: Arc::new(pair), dynamic: false }); + let place = Affine::translate((40.0, 30.0)) * Affine::scale(3.0); + let mut on_layer = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Pair".into(), xf: place }); + let in_mask = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place }); + on_layer.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(in_mask, true))); + doc.insert(Some(doc.layers[0].id), 0, on_layer).unwrap(); + let tiled = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place }); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)])); + // Each instance's box on the page, by the art of Box it places (through Pair's instance). + let placed = |d: &Document| { + let xf = |n: &Node| match &n.kind { + NodeKind::SymbolInstance { xf, .. } => *xf, + other => panic!("an instance: {other:?}"), + }; + let art = d.symbols[0].art.geometric_bounds().unwrap(); + let layer = &d.layers[0].children().unwrap()[0]; + let mask = &layer.extras().mask.as_ref().unwrap().art; + let tile = &d.patterns[0].art[0]; + let nested = &d.symbols[1].art.children().unwrap()[0]; + let masking = &d.symbols[1].art.extras().mask.as_ref().unwrap().art; + [xf(layer) * xf(nested), xf(layer) * xf(masking), xf(mask), xf(tile)].map(|m| m.transform_rect_bbox(art)) + }; + let before = placed(&doc); + scale_symbols(&mut doc, 10.0); + let after = placed(&doc); + for (a, b) in after.iter().zip(&before) { + let far = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(far < 1e-9, "{after:?} {before:?}"); + } + let pair = &doc.symbols[1].art.children().unwrap()[0]; + let NodeKind::SymbolInstance { xf, .. } = &pair.kind else { panic!("an instance") }; + let want = Affine::translate((30.0, 40.0)) * Affine::rotate(0.5) * Affine::scale(2.0); + let far = xf.as_coeffs().iter().zip(want.as_coeffs()).fold(0.0f64, |m, (a, b)| m.max((a - b).abs())); + assert!(far < 1e-9, "{xf:?}"); + let art = doc.symbols[0].art.geometric_bounds().unwrap(); + assert_eq!(art, Rect::new(-10.0, -5.0, 10.0, 5.0)); + } + + /// Layers read in the units of a page scaled up to points take the art of their opacity masks + /// with them, a mask that doesn't move with its object included, except in data VectorCraft + /// wrote, where that mask's art is at its own size. + #[test] + fn scaling_a_read_document_scales_unlinked_opacity_masks() { + for own_size in [false, true] { + let mut doc = Document::new(100.0, 100.0); + let mut boxes = vec![]; + for linked in [true, false] { + let mask = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(1.0, 2.0, 3.0, 4.0)), Default::default()); + let mut mask = vectorcraft_doc::OpacityMask::new(mask, true); + mask.linked = linked; + let mut n = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Default::default()); + n.extras_mut().mask = Some(Box::new(mask)); + boxes.push((linked, doc.insert(Some(doc.layers[0].id), 0, n).unwrap())); + } + scale_read_document(&mut doc, 10.0, false, own_size); + for (linked, id) in boxes { + let mask = doc.node(id).and_then(|n| n.extras().mask.as_ref().and_then(|m| m.art.geometric_bounds())); + let want = if linked || !own_size { Rect::new(10.0, 20.0, 30.0, 40.0) } else { Rect::new(1.0, 2.0, 3.0, 4.0) }; + assert_eq!(mask, Some(want), "linked {linked}, own size {own_size}"); + } + } + } + /// Area type in `doc`'s first layer at `index`, in the frame `r`. fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId { let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default()); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index d1af3301df..afd8f2568c 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,6 @@ -use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art}; +use super::build::{ + PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art, scale_symbol_art, +}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -45,7 +47,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus let own_palette = use_palette(data, &mut v.document, &mut read); use_brushes(data, &mut v.document, have, &mut v.warnings, &mut read); use_styles(data, &mut v.document, true, 1.0, None, &mut read); - use_symbols(data, &mut v.document, own_palette, &mut v.warnings, &mut read); + use_symbols(data, &mut v.document, own_palette, None, &mut v.warnings, &mut read); use_variables(data, &mut v.document, &mut v.warnings, &mut read); add_notes(&mut v.warnings, read.notes()); } @@ -153,7 +155,7 @@ pub fn layered_ai_from( let own_palette = use_palette(&data, &mut visible, &mut read); use_brushes(&data, &mut visible, have, &mut warnings, &mut read); use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read); - use_symbols(&data, &mut visible, own_palette, &mut warnings, &mut read); + use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut warnings, &mut read); use_variables(&data, &mut visible, &mut warnings, &mut read); add_notes(&mut warnings, read.notes()); return (visible, warnings, false); @@ -172,7 +174,7 @@ pub fn layered_ai_from( use_brushes(&data, &mut visible, have, &mut warnings, &mut read); use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read); let mut all = unread(warnings, why); - use_symbols(&data, &mut visible, own_palette, &mut all, &mut read); + use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut all, &mut read); use_variables(&data, &mut visible, &mut all, &mut read); add_notes(&mut all, read.notes()); (visible, all, false) @@ -377,26 +379,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo if styles.is_empty() { return; } - if !large_canvas && (unit != 1.0 || canvas != 1.0) { - let read; - let boards = match boards { - Some(b) => b, - None => { - read = ai::artboards(data, &mut ai::Names::default()).0; - &read - } - }; - let k = board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)); - if let Some(k) = k.filter(|k| *k != 1.0) { - for g in &mut styles { - g.appearance.scale_strokes(k); - g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); - } + if !large_canvas && let Some(k) = data_scale(data, doc, unit, canvas, boards) { + for g in &mut styles { + g.appearance.scale_strokes(k); + g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); } } doc.graphic_styles = styles.into(); } +/// How many times as large `doc` (a file read as its PDF part) draws the art of its editing data +/// `data`, whose declared large canvas scale is `canvas`, when it isn't 1: `unit` when the editing +/// data's artboards (`boards`, or read from `data`) are the size of `doc`'s pages in units of +/// `unit` points, else `canvas` when they are the pages' size at that scale ([`board_scale`]). +fn data_scale(data: &[u8], doc: &Document, unit: f64, canvas: f64, boards: Option<&[(String, Rect)]>) -> Option<f64> { + if unit == 1.0 && canvas == 1.0 { + return None; + } + let read; + let boards = match boards { + Some(b) => b, + None => { + read = ai::artboards(data, &mut ai::Names::default()).0; + &read + } + }; + board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)).filter(|k| *k != 1.0) +} + /// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its /// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and /// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name @@ -405,15 +415,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo /// of the name, which the art then names, as does one whose name a color group has), as [`build`] gives a document read from the layers, and /// the note about instances that can't be drawn added to `warnings`. An object of their art linked /// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The -/// symbols' art keeps the editing data's units, as in a document read from the layers: when -/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art -/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has -/// none this reads. `names` reads the symbols' names and keeps the notes on them. -fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec<String>, names: &mut ai::Names) { +/// symbols' art is drawn as large as a document read from the layers draws it +/// ([`scale_symbol_art`]): with `units` `None` (an EPS's page), as many times as large as the data's +/// declared large canvas scale; with `Some((unit, boards))`, as many times as large as +/// [`use_styles`] draws the styles' strokes and effects ([`data_scale`], `boards` the editing data's +/// artboards); at its own size in data VectorCraft wrote. Left as it is when `doc` has symbols +/// already or the editing data has none this reads. `names` reads the symbols' names and keeps the +/// notes on them. +fn use_symbols( + data: &[u8], + doc: &mut Document, + own_palette: bool, + units: Option<(f64, &[(String, Rect)])>, + warnings: &mut Vec<String>, + names: &mut ai::Names, +) { if !doc.symbols.is_empty() { return; } - let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data, names) else { return }; + let Some(ai::Symbols { symbols, images, swatches, canvas_scale, own_size }) = ai::symbols(data, names) else { return }; + let (mut symbols, registrations): (Vec<_>, Vec<_>) = symbols.into_iter().unzip(); + let k = match units { + _ if own_size => None, + None => Some(canvas_scale), + Some((unit, boards)) => data_scale(data, doc, unit, canvas_scale, Some(boards)), + }; + if let Some(k) = k { + scale_symbol_art(&mut symbols, k); + } for (key, blob) in images { doc.images.entry(key).or_insert(blob); } @@ -444,7 +473,7 @@ fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mu *link = Some(new.clone()); true }; - for (mut symbol, registration) in symbols { + for (mut symbol, registration) in symbols.into_iter().zip(registrations) { let mut art = reid_page_art(doc, &symbol.art); if !renamed.is_empty() { vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 1f634b3744..738f02d857 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1809,6 +1809,44 @@ fn an_eps_without_layers_keeps_its_symbols() { assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings); } +/// The box of the art of `d`'s first symbol (Bar, 12 × 2 about the origin in the editing data). +fn bar_bounds(d: &Document) -> Option<vectorcraft_geom::Rect> { + d.symbols.first().and_then(|s| s.art.geometric_bounds()) +} + +/// A symbol library without layers whose editing data is drawn larger than its units comes in with +/// its symbols' art as large as a document read from its layers has it: an EPS of a Large Canvas +/// copy at its declared scale, and a `.ai` whose artboard is its PDF part's page at that scale or +/// in the units of its `UserUnit`. One whose artboard is the page in points keeps its art's size. +#[test] +fn a_symbol_library_in_larger_units_comes_in_at_the_pages_size() { + let board = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let library = |setup: &str| symbol_library().replace("%%BeginSetup\n", &format!("%%BeginSetup\n{setup}")); + let ten = Some(vectorcraft_geom::Rect::new(-60.0, -10.0, 60.0, 10.0)); + let r = import(&eps(&page_square(50, 50), &library("%AI24_LargeCanvasScale: 10\n"))).unwrap(); + assert_eq!(bar_bounds(&r.document), ten, "{:?}", r.warnings); + let large = format!("%AI24_LargeCanvasScale: 10\n{board}"); + let (d, warnings, read) = crate::layered_ai(&private(&library(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + assert_eq!(bar_bounds(&d), ten, "{warnings:?}"); + let (d, warnings, _) = crate::layered_ai(&private(&library(board)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0); + assert_eq!(bar_bounds(&d), ten, "{warnings:?}"); + let points = board.replace("0 100 /Real", "0 1000 /Real").replace("100 0 /Real", "1000 0 /Real"); + let (d, warnings, _) = crate::layered_ai(&private(&library(&points)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0); + assert_eq!(bar_bounds(&d), Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)), "{warnings:?}"); + // A library VectorCraft wrote (its `%%Creator`) has its symbols at their own size, whatever its + // scale. + let own = + |setup: &str| library(setup).replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {}\n", crate::ai_editing::CREATOR_TEXT), 1); + let own_size = Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)); + let r = import(&eps(&page_square(50, 50), &own("%AI24_LargeCanvasScale: 10\n"))).unwrap(); + assert_eq!(bar_bounds(&r.document), own_size, "{:?}", r.warnings); + let (d, warnings, _) = crate::layered_ai(&private(&own(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0); + assert_eq!(bar_bounds(&d), own_size, "{warnings:?}"); +} + /// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their /// layers can't be used (their editing data is for another page) get the symbols defined before /// their layers. @@ -2769,6 +2807,94 @@ fn a_large_canvas_eps_keeps_its_graphic_styles_at_the_pages_size() { assert_eq!((object.appearance.stroke_width(), object.extras().graphic_style), (2.0, Some(1)), "{object:#?}"); } +/// The definition of the symbol Box: a rectangle 2 × 1 about the origin, stroked 0.4 wide. +fn stroked_box_symbol() -> String { + let art = "0 0 0 1 K\n0.4 w\n-1 -0.5 m\n-1 0.5 L\n1 0.5 L\n1 -0.5 L\n-1 -0.5 L\nS\n"; + format!("%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Box", art, 5)) +} + +/// The art of `d`'s symbol Box ([`stroked_box_symbol`]) is ten times as large, its stroke 4 wide, +/// and its instance on `d`'s second layer, placed in the file at twice its size about (`x`, `y`), +/// keeps that scale and draws a box of 40 × 20 about `center`. +fn box_symbol_at_ten_times(d: &Document, center: (f64, f64), notes: &[String]) { + let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{notes:?}")).art; + let b = art.geometric_bounds().unwrap(); + let close = |r: vectorcraft_geom::Rect, e: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(e).all(|(a, b)| (a - b).abs() < 1e-6); + assert!(close(b, [-10.0, -5.0, 10.0, 5.0]), "the art at the page's size: {b:?} {notes:?}"); + let mut widths = vec![]; + art.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) { + widths.push(n.appearance.stroke_width()); + } + }); + assert_eq!(widths, [4.0], "{notes:?}"); + let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{:#?} {notes:?}", d.layers)); + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {:?}", instance.kind) }; + let [a, b0, c, d0, ..] = xf.as_coeffs(); + assert!((a.hypot(b0) - 2.0).abs() < 1e-9 && (c.hypot(d0) - 2.0).abs() < 1e-9, "the instance's own scale: {xf:?}"); + let (x, y) = center; + assert!(close(xf.transform_rect_bbox(b), [x - 20.0, y - 10.0, x + 20.0, y + 10.0]), "{:?}", xf.transform_rect_bbox(b)); +} + +/// The symbols of a Large Canvas editing copy come in at the page's size, as its art does: their +/// art and its strokes ten times as large, and each instance with the scale the file gives it, +/// drawn where and as large as before. +#[test] +fn a_large_canvas_eps_keeps_its_symbols_at_the_pages_size() { + let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 5 5 /RTransformMatrix\n;\n9 () XW"; + let square = "0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf"; + let art = native_large_canvas(10.0, 10.0, &format!("{}{}{}", stroked_box_symbol(), layer("Art", true, square), layer("Box", false, instance))); + let r = import(&eps(&page_square(50, 50), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Box"], "{:?}", r.warnings); + box_symbol_at_ten_times(&r.document, (50.0, 50.0), &r.warnings); +} + +/// A large canvas `.ai` 100 units square at a scale of 10, with the symbol Box +/// ([`stroked_box_symbol`]) and an instance of it at twice its size about (50, 50), its header +/// carrying `creator` when given, read with its PDF part, on a page whose PDF part can't be read, +/// and without a page. +fn large_canvas_ai_box_reads(creator: Option<&str>) -> Vec<(&'static str, Document, Vec<String>)> { + let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 50 50 /RTransformMatrix\n;\n9 () XW"; + let layers = format!("{}{}{}", stroked_box_symbol(), layer("Art", true, &square(10, 10)), layer("Box", false, instance)); + let mut art = native_large_canvas(100.0, 10.0, &layers); + if let Some(creator) = creator { + art = art.replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {creator}\n"), 1); + } + let data = private(&art); + let (d, notes, read) = crate::layered_ai(&data, in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let on_page = crate::ai_alone_on_page(&data, (1000.0, 1000.0), 10.0, Default::default()).unwrap(); + let no_page = crate::ai_alone(&data).unwrap(); + vec![("PDF part", d, notes), ("on a page", on_page.0, on_page.1), ("no page", no_page.0, no_page.1)] +} + +/// A `.ai` whose editing data is read at its declared large canvas scale has its symbols scaled +/// with the art, as an EPS has, however the file opens. +#[test] +fn a_large_canvas_ai_keeps_its_symbols_at_the_pages_size() { + for (how, d, notes) in large_canvas_ai_box_reads(None) { + let notes: Vec<String> = notes.into_iter().chain([how.to_string()]).collect(); + box_symbol_at_ten_times(&d, (500.0, 500.0), ¬es); + } +} + +/// A large canvas `.ai` that VectorCraft wrote (its `%%Creator`) has its symbols defined at their +/// own size, as its `.ai` save writes them: their art keeps that size, and the instance, scaled with +/// the layers, draws the same box, however the file opens. +#[test] +fn a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size() { + for (how, d, notes) in large_canvas_ai_box_reads(Some(crate::ai_editing::CREATOR_TEXT)) { + let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{how}: {notes:?}")).art; + let b = art.geometric_bounds().unwrap(); + assert_eq!(b, vectorcraft_geom::Rect::new(-1.0, -0.5, 1.0, 0.5), "{how}: {notes:?}"); + let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{how}: {:#?} {notes:?}", d.layers)); + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("{how}: an instance: {:?}", instance.kind) }; + let drawn = xf.transform_rect_bbox(b); + let close = [drawn.x0 - 480.0, drawn.y0 - 490.0, drawn.x1 - 520.0, drawn.y1 - 510.0].iter().all(|v| v.abs() < 1e-6); + assert!(close, "{how}: {drawn:?}"); + } +} + #[test] fn type_with_a_listed_art_style_is_linked_to_its_graphic_style() { let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..083dc9fb84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1675,6 +1675,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). A `.ai` or EPS symbol library, whose editing data holds symbol definitions and their list but no layers, opens with those symbols in list order, each with its registration point. Other Library… in the Symbols panel's Symbol Libraries Menu imports the symbols of the file you pick ([Create or import symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)) and lists such a library's symbols in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the symbols defined before its layers (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ai/tests.rs`, `cmd::fileio::tests_aiimport::a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols`). + In a large canvas `.ai` or EPS ([Large canvas FAQ](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/large-canvas-faq.html)), or a `.ai` whose page is in larger units, opened from its editing data, VectorCraft reads the symbol definitions in the same units as the layers. When the file opens at its full size, each symbol's art, strokes and effects are scaled with the layers, and each instance keeps its own scale and rotation and draws where and as large as before. A new instance comes in at the symbol's full size, the size of an instance at 100% in the file. A symbol library from such a file comes in at the same size, and opacity masks on the layers that don't move with their object are scaled with the layers too. Editing data that VectorCraft wrote keeps its symbols and those masks at their own size, as its `.ai` save writes them (`tests_native::a_large_canvas_eps_keeps_its_symbols_at_the_pages_size`, `a_large_canvas_ai_keeps_its_symbols_at_the_pages_size`, `a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size`, `a_symbol_library_in_larger_units_comes_in_at_the_pages_size`, `build::candidate_tests::scaling_the_symbols_keeps_their_instances_in_place`, `scaling_a_read_document_scales_unlinked_opacity_masks`). A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). To find those characters, SVG export lays a type object out a second time only when the second layout can differ from the first: point type with Line-end Punctuation Half Width and no Japanese punctuation or letters, the setting new type gets, is laid out once. Exporting 5,000 such objects takes a median of 109.4 ms instead of 171.5 ms on Windows 11, with the same SVG (`crates/svg/src/export/text/tests.rs`). From b59ec11b3297a41829b982cde63a7e52241bc941 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 02:45:49 -0800 Subject: [PATCH 35/89] Export: symbol art placement skips instances the export leaves out Hidden instances, instances in hidden groups, on template layers, and on hidden layers or layers that do not print when the exporter leaves those out are no longer placed for PDF, print, EPS and DXF. Their art was baked and counted toward the 16,000,000-object limit for placed symbol art, so it could leave a visible instance written without its effects. bake_document_placing_symbols takes a WrittenLayers value: PDF passes the layers its settings write, EPS writes layers that do not print but not hidden ones, and DXF writes both. Print removes the layers that do not print before baking. --- crates/cad/src/lib.rs | 3 +- crates/effects/src/bake.rs | 86 +++++++++++++++++++++++++------- crates/effects/src/bake/tests.rs | 75 +++++++++++++++++++++++----- crates/effects/src/lib.rs | 3 +- crates/eps/src/lib.rs | 3 +- crates/pdf/src/export/entry.rs | 8 ++- crates/pdf/src/print.rs | 13 +++-- docs/target-app-parity.md | 2 +- 8 files changed, 153 insertions(+), 40 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index d459f95b40..bb752267aa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -429,7 +429,8 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result<DxfOutput, String> { return Err("the export region is not finite".into()); } // Symbols' art is placed without clipping, as the scene places it. - let baked = vectorcraft_effects::bake_document_placing_symbols(doc, false); + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, false, vectorcraft_effects::WrittenLayers { hidden: true, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); Ok(scene::Scene::new(doc, opts).run()) } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 0ce8fb8aef..12f967922d 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -124,6 +124,9 @@ thread_local! { /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer /// is 1 deep. static LEVEL: std::cell::Cell<usize> = const { std::cell::Cell::new(1) }; + /// Whether [`bake_node`] is baking inside an object the export does not write ([`unwritten`]), + /// where no instance is placed. + static UNWRITTEN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; } /// `f` run for an object one level deeper ([`LEVEL`]) than the one being baked. @@ -149,6 +152,30 @@ struct Placing { /// How deep the document's symbols and patterns draw, for the instances and pattern paints /// left in placed art. heights: vectorcraft_doc::depth::DrawnHeights, + /// The hidden layers and layers that do not print that the export writes. + layers: WrittenLayers, +} + +/// The layers an exporter writes besides the shown, printing ones +/// ([`bake_document_placing_symbols`]). Template layers are never written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WrittenLayers { + /// Hidden layers (written turned off, as PDF layers and DXF write them). + pub hidden: bool, + /// Layers that do not print. + pub non_printing: bool, +} + +/// Does the export leave `n` and everything in it out, while [`bake_document_placing_symbols`] +/// bakes? A hidden object other than a layer, a template layer, and a hidden layer or a layer +/// that does not print when the export leaves those out ([`WrittenLayers`]). +fn unwritten(n: &Node) -> bool { + PLACING.with(|p| { + p.borrow().as_ref().is_some_and(|p| match n.kind { + NodeKind::Layer { template, printable, .. } => template || !(printable || p.layers.non_printing) || !(n.visible || p.layers.hidden), + _ => !n.visible, + }) + }) } /// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, @@ -201,13 +228,14 @@ fn drawn_symbols(d: &Document) -> HashSet<String> { } /// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces -/// by its placed art? False outside it. +/// by its placed art? False outside it, and for objects the export does not write ([`unwritten`]). fn holds_placed_symbol(n: &Node) -> bool { fn walk(n: &Node, symbols: &HashSet<String>) -> bool { - matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) - || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols))) + !unwritten(n) + && (matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) + || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols)))) } - PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) + !UNWRITTEN.with(|c| c.get()) && PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) } /// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it @@ -216,14 +244,15 @@ fn holds_placed_symbol(n: &Node) -> bool { /// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and /// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an /// instance that scales its art does not scale what its effects do. `None` for other objects, -/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art +/// for instances the export does not write ([`unwritten`], hidden or inside such an object), for +/// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art /// as placed, before its effects are baked, would already take the art placed for the export /// past [`Placing::most_nodes`]; those instances stay as they are. [`bake_node`] checks the baked /// art with [`placed_fits`]. fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; - if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH { + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || UNWRITTEN.with(|c| c.get()) || unwritten(n) { return None; } let sym = d.symbols.iter().find(|s| s.name == *symbol)?; @@ -242,6 +271,13 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option<Node> { + // Inside an object the export leaves out, effects are baked but no instance is placed. + if !UNWRITTEN.with(|c| c.get()) && unwritten(n) { + UNWRITTEN.with(|c| c.set(true)); + let out = bake_node(d, n); + UNWRITTEN.with(|c| c.set(false)); + return out; + } // A cancelled export leaves the rest of the document as it is: the caller drops the result. // A ruler guide draws nothing and stays as it is, last in its layer. if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() { @@ -848,31 +884,47 @@ pub fn bake_document(doc: &Document) -> Option<Document> { /// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than /// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped /// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the -/// exporter places other instances. The symbols' own art is left as it is. -pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool) -> Option<Document> { - bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES))) +/// exporter places other instances. `layers`: the hidden layers and layers that do not print +/// that the exporter writes. Instances the export does not write (hidden ones, those in hidden +/// objects, on template layers, and on the layers `layers` leaves out) stay as they are. The +/// symbols' own art is left as it is. +pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool, layers: WrittenLayers) -> Option<Document> { + bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES, layers))) } /// Restores what [`bake`] changes in the thread's baking state when it ends. -struct Restore(Option<Rc<Placing>>, u64, usize); +struct Restore(Option<Rc<Placing>>, u64, usize, bool); impl Drop for Restore { fn drop(&mut self) { PLACING.with(|p| *p.borrow_mut() = self.0.take()); PLACED_NODES.with(|c| c.set(self.1)); LEVEL.with(|c| c.set(self.2)); + UNWRITTEN.with(|c| c.set(self.3)); } } -/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices` -/// and [`Placing::most_nodes`]. -fn bake(doc: &Document, placing: Option<(bool, u64)>) -> Option<Document> { +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`, +/// [`Placing::most_nodes`] and [`Placing::layers`]. +fn bake(doc: &Document, placing: Option<(bool, u64, WrittenLayers)>) -> Option<Document> { let placed = doc.with_placed_art(); - let placing = placing.map(|(clip_slices, most_nodes)| (drawn_symbols(&placed), clip_slices, most_nodes)).filter(|p| !p.0.is_empty()).map( - |(symbols, clip_slices, most_nodes)| Placing { symbols, clip_slices, most_nodes, heights: vectorcraft_doc::depth::DrawnHeights::of(&placed) }, - ); + let placing = placing + .map(|(clip_slices, most_nodes, layers)| (drawn_symbols(&placed), clip_slices, most_nodes, layers)) + .filter(|p| !p.0.is_empty()) + .map(|(symbols, clip_slices, most_nodes, layers)| Placing { + symbols, + clip_slices, + most_nodes, + heights: vectorcraft_doc::depth::DrawnHeights::of(&placed), + layers, + }); // A bake inside another one (of art an effect makes) places symbols only when it asks to. - let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0)), LEVEL.with(|c| c.replace(1))); + let _restore = Restore( + PLACING.with(|p| p.replace(placing.map(Rc::new))), + PLACED_NODES.with(|c| c.replace(0)), + LEVEL.with(|c| c.replace(1)), + UNWRITTEN.with(|c| c.replace(false)), + ); if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index be72b4df6e..ed7699651d 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1100,7 +1100,7 @@ fn placing_symbols_bakes_their_art_where_each_instance_places_it() { d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap(); add_symbol(&mut d, "S", moved_square(800)); assert!(bake_document(&d).is_none(), "nothing in the layers needs baking"); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. })); assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900)); assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901)); @@ -1115,7 +1115,7 @@ fn placing_symbols_reaches_the_art_of_nested_instances() { let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); add_symbol(&mut d, "Inner", moved_square(800)); add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))])); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900)); } @@ -1130,7 +1130,7 @@ fn placing_a_symbol_that_holds_itself_stops() { "Loop", Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]), ); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); let mut lefts = vec![]; out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0))); assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}"); @@ -1160,7 +1160,7 @@ fn placing_symbols_keeps_dynamic_instances_changes() { let mut d = doc_with(inst); add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)])); d.symbols[0].dynamic = true; - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); let mut fills = vec![]; out.node(NodeId(900)).unwrap().walk(&mut |n| { if n.path_data().is_some() @@ -1197,7 +1197,7 @@ fn placing_symbols_keeps_the_instances_stain() { art.appearance.effects.push(fx(id, params)); let mut d = doc_with(inst.clone()); add_symbol(&mut d, "Mark", art); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); assert!(!is_instance(&out, 900), "{id}: the instance is placed"); let mut fills = vec![]; out.node(NodeId(900)).unwrap().walk(&mut |n| { @@ -1209,6 +1209,9 @@ fn placing_symbols_keeps_the_instances_stain() { } } +/// Every layer written, as DXF writes them (template layers are never written). +const ALL: WrittenLayers = WrittenLayers { hidden: true, non_printing: true }; + fn is_instance(d: &Document, id: u64) -> bool { d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) } @@ -1223,13 +1226,13 @@ fn placing_symbols_counts_the_baked_art() { let layer = d.layers[0].id; d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)))).unwrap(); add_symbol(&mut d, "S", gradient_copies(800)); - let all = bake(&d, Some((true, u64::MAX))).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); let each = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); assert!(each > 2, "the instance's group, and the copies as objects of their own: {each}"); assert_eq!(all.node(NodeId(901)).map(|n| n.count_with_masks() as u64), Some(each)); - let fits = bake(&d, Some((true, 2 * each))).unwrap(); + let fits = bake(&d, Some((true, 2 * each, ALL))).unwrap(); assert!(!is_instance(&fits, 900) && !is_instance(&fits, 901)); - let short = bake(&d, Some((true, 2 * each - 1))).unwrap(); + let short = bake(&d, Some((true, 2 * each - 1, ALL))).unwrap(); assert_eq!(usize::from(is_instance(&short, 900)) + usize::from(is_instance(&short, 901)), 1, "one instance stays as it is"); } @@ -1241,14 +1244,60 @@ fn placing_symbols_counts_nested_art_once() { let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); add_symbol(&mut d, "Inner", gradient_copies(800)); add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::IDENTITY))])); - let all = bake(&d, Some((true, u64::MAX))).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); let total = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); - let exact = bake(&d, Some((true, total))).unwrap(); + let exact = bake(&d, Some((true, total, ALL))).unwrap(); assert!(!is_instance(&exact, 900) && !is_instance(&exact, 802), "both are placed within {total}"); - let short = bake(&d, Some((true, total - 1))).unwrap(); + let short = bake(&d, Some((true, total - 1, ALL))).unwrap(); assert!(is_instance(&short, 900), "the outer instance stays as it is"); } +/// Instances the export does not write are not placed and do not count against the budget: with +/// room for one instance's baked art, hidden instances, an instance in a hidden group, one on a +/// template layer, one on a layer that does not print and one on a hidden layer (when the export +/// leaves those layers out) come first and stay as they are, and the visible instance after them +/// is placed. An export that writes those layers places their instances first. +#[test] +fn placing_symbols_skips_instances_the_export_leaves_out() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + add_symbol(&mut d, "S", gradient_copies(800)); + let each = bake(&d, Some((true, u64::MAX, ALL))).unwrap().node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let layer = d.layers[0].id; + let mut hidden = instance(901, "S", Affine::IDENTITY); + hidden.visible = false; + let mut group = Node::group(NodeId(902), vec![Arc::new(instance(903, "S", Affine::IDENTITY))]); + group.visible = false; + for (i, n) in [hidden, group].into_iter().enumerate() { + d.insert(Some(layer), i, n).unwrap(); + } + let mut left_out = vec![]; + for (id, template, printable, visible) in [(910, true, true, true), (920, false, false, true), (930, false, true, false)] { + let mut l = Node::layer(NodeId(id), &format!("L{id}"), vectorcraft_doc::LayerColor::Preset(0)); + if let NodeKind::Layer { template: t, printable: p, .. } = &mut l.kind { + *t = template; + *p = printable; + } + l.visible = visible; + if let Some(ch) = l.children_mut() { + ch.push(Arc::new(instance(id + 1, "S", Affine::IDENTITY))); + } + left_out.push(Arc::new(l)); + } + d.layers.splice(0..0, left_out); + let out = bake(&d, Some((true, each, WrittenLayers::default()))).unwrap(); + assert!(!is_instance(&out, 900), "the visible instance is placed within {each}"); + for id in [901, 903, 911, 921, 931] { + assert!(is_instance(&out, id), "{id} stays as it is"); + } + // An export that writes the layers that do not print, or the hidden layers, places that + // layer's instance first; the template layer's instance is never placed. + for (layers, first) in [(WrittenLayers { non_printing: true, hidden: false }, 921), (WrittenLayers { non_printing: false, hidden: true }, 931)] { + let out = bake(&d, Some((true, each, layers))).unwrap(); + assert!(!is_instance(&out, first) && is_instance(&out, 900) && is_instance(&out, 911), "{layers:?}"); + } +} + /// `leaf` in `levels` groups, with ids from `id` up. fn in_groups(id: u64, levels: u64, leaf: Node) -> Node { (0..levels).fold(leaf, |n, i| Node::group(NodeId(id + i), vec![Arc::new(n)])) @@ -1270,7 +1319,7 @@ fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); assert!(!d.too_deep(None)); assert_eq!(DrawnHeights::of(&d).deepest(&d.layers[0], 1), MAX_DRAWN_DEPTH); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); // E's square becomes a group of copies, a level past the cap: E's instance stays. assert!(is_instance(&out, 4100), "E's instance stays as it is"); assert!(!is_instance(&out, 900) && !is_instance(&out, 3100), "A to D are placed"); @@ -1278,5 +1327,5 @@ fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { // Without the chain above it, E's art is placed. let mut shallow = doc_with(instance(900, "E", Affine::IDENTITY)); add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); - assert!(!is_instance(&bake_document_placing_symbols(&shallow, true).unwrap(), 900)); + assert!(!is_instance(&bake_document_placing_symbols(&shallow, true, ALL).unwrap(), 900)); } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 2b5d561021..3d74ac4fe0 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,8 @@ pub use adjust::{ is_channel, }; pub use bake::{ - StrokeArt, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake, + StrokeArt, WrittenLayers, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, + fresh_ids, needs_bake, }; pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 2dbadf4fea..534778286a 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -230,7 +230,8 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb } // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); - let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true); + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, true, vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); let mut setting_notes = vec![]; diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 96dc97a6f8..6cf8677269 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -47,7 +47,13 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; // Live geometry effects export as their result, in symbols' art too; raster effects are // reported below. - let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true); + // Only the layers this export writes place their instances' art (see `ex.non_printing` and + // `ex.layers` below). + let layers = vectorcraft_effects::WrittenLayers { + hidden: set.writes_layers() && !doc.page_knockout, + non_printing: set.include_non_printing || set.create_layers, + }; + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true, layers); // Baking stops early when cancelled, leaving a half-baked copy that must not be written. vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 57d30e327d..2a3cbf98af 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -183,11 +183,14 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) { /// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`] /// says. pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> { - let mut doc = vectorcraft_effects::bake_document_placing_symbols(doc, true).map_or(Cow::Borrowed(doc), Cow::Owned); - let d = doc.to_mut(); - keep_layers(&mut d.layers, set.print_layers); - composite_overprints(d, set); - doc + // The layers that do not print are left out first, so the art placed for symbols is only + // the art that prints; the layers kept are shown. + let mut kept = doc.clone(); + keep_layers(&mut kept.layers, set.print_layers); + let layers = vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true }; + let mut d = vectorcraft_effects::bake_document_placing_symbols(&kept, true, layers).unwrap_or(kept); + composite_overprints(&mut d, set); + Cow::Owned(d) } /// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9f587d0e4..766f58d944 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1575,7 +1575,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`). A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). From 083180075c6e741f0fd0464a4aee5000def84115 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 21:56:08 -0800 Subject: [PATCH 36/89] Graphs: a black fill set on wedge legend labels stays black on dark wedges On a pie with Legends in Wedges, the graph draws legend labels white on dark wedges unless they keep a fill of their own, and it read a kept black fill as no fill of its own. A label recolored from white to black on a dark wedge went back to white the next time the graph was drawn. The graph now marks the legend labels' fill as set when a command sets it (legendFillSet in the graph's label styles, kept through save and open), and draws a marked fill on every wedge, black included. Undo clears the mark. A size, font or stroke change does not set it, and neither does a whole-pie recolor that gives the legend labels different fills; one that gives them all the same fill sets it. A swatch edit or color conversion leaves the default black legend fill as it is, and keeps a non-black legend fill from a file saved without the mark as the labels' own when it turns black. --- crates/doc/src/graph.rs | 16 ++++++ crates/doc/src/swatches.rs | 72 +++++++++++++++++++++++++-- crates/engine/src/cmd/colormgmt.rs | 11 +++- crates/engine/src/cmd/graph/series.rs | 33 ++++++++---- crates/engine/src/cmd/graph/tests.rs | 66 ++++++++++++++++++++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 7 files changed, 185 insertions(+), 18 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index d561663524..b387eecb2a 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -150,6 +150,10 @@ pub struct LabelStyles { pub categories: Option<crate::CharStyle>, #[serde(skip_serializing_if = "Option::is_none")] pub legend: Option<crate::CharStyle>, + /// The fill of [`Self::legend`] was set on the legend labels, so it is drawn on every pie wedge with Legends in + /// Wedges. `false`: a black fill there is the default, and the labels on dark wedges are drawn white. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub legend_fill_set: bool, #[serde(skip_serializing_if = "Option::is_none")] pub values_para: Option<crate::ParaStyle>, #[serde(skip_serializing_if = "Option::is_none")] @@ -182,6 +186,18 @@ impl LabelStyles { fn is_default(&self) -> bool { *self == Self::default() } + + /// Is the kept legend fill the legend labels' own: set on them ([`Self::legend_fill_set`]), or not black (a file + /// saved without the mark)? Otherwise it is the default black, which edits that map the kept paints leave as it is. + pub fn legend_fill_is_own(&self) -> bool { + self.legend.as_ref().is_some_and(|st| self.legend_fill_set || st.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK)) + } + + /// Mark a kept legend fill that is the labels' own ([`Self::legend_fill_is_own`]) as set on them, before an edit + /// that maps the kept paints can turn it black. + pub fn mark_own_legend_fill(&mut self) { + self.legend_fill_set = self.legend_fill_is_own(); + } } /// Graph Type › Position for pie graphs with several pies: the same size (Even), sized by their totals (Ratio), or diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 844aa8c810..cab2115fda 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -206,11 +206,13 @@ impl Document { impl Document { /// Visit every paint held by the art (opacity masks included), symbol definitions, pattern /// tiles and graphic styles (fills, strokes and text runs), and the colour of every - /// gradient-mesh point as a solid paint. + /// gradient-mesh point as a solid paint. A graph's default black legend fill is left out. pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { let mut tree = |n: &Node| { walk_with_masks(n, &mut |m| { - node_paints(m).chain(override_paints(m)).chain(graph_paints(m)).for_each(&mut *f); + node_paints(m).chain(override_paints(m)).for_each(&mut *f); + let default_legend = default_legend_fill(m); + graph_paints(m).enumerate().filter(|(i, _)| Some(*i) != default_legend).for_each(|(_, p)| f(p)); if let NodeKind::Mesh(mesh) = &m.kind { mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); } @@ -404,6 +406,18 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator<Item = &mu .chain(rows.flat_map(|r| [r.paint.fill.as_mut(), r.paint.stroke.as_mut()]).flatten()) } +/// The place in [`graph_paints`] of the legend fill graph `n` keeps when it is the default black and not the legend +/// labels' own ([`crate::graph::LabelStyles::legend_fill_is_own`]). +fn default_legend_fill(n: &Node) -> Option<usize> { + let g = n.extras().graph.as_deref()?; + let l = &g.label_styles; + if l.legend.is_none() || l.legend_fill_is_own() { + return None; + } + let series: usize = g.series_paints.iter().map(|s| usize::from(s.fill.is_some()) + usize::from(s.stroke.is_some())).sum(); + Some(series + 2 * (usize::from(l.values.is_some()) + usize::from(l.categories.is_some()))) +} + /// Mutable [`node_paints`], in the same order. pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> { let runs = match &mut n.kind { @@ -446,11 +460,16 @@ fn map_node(n: &Node, f: &mut PaintFn) -> Option<(Node, usize)> { } } // A graph's series and label paints, which its marks and labels take when it is drawn again. - let graph: Vec<(usize, Paint)> = graph_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect(); + // The default black legend fill, which is not the legend labels' own, stays black. + let default_legend = default_legend_fill(n); + let graph: Vec<(usize, Paint)> = + graph_paints(n).enumerate().filter(|(i, _)| Some(*i) != default_legend).filter_map(|(i, p)| f(p).map(|q| (i, q))).collect(); if !graph.is_empty() { count += graph.len(); let m = out.get_or_insert_with(|| n.clone()); if let Some(spec) = m.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + // A legend fill of the labels' own stays theirs when the change makes it black. + spec.label_styles.mark_own_legend_fill(); let mut graph = graph.into_iter().peekable(); for (i, p) in graph_paints_mut(spec).enumerate() { if graph.peek().is_some_and(|c| c.0 == i) @@ -720,6 +739,53 @@ mod tests { assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none)); } + #[test] + fn a_legend_fill_mapped_to_black_stays_the_labels_own() { + let (mut d, a, _) = doc(); + let red = linked(Color::rgb(1.0, 0.0, 0.0), "Brand"); + let spec = crate::graph::GraphSpec { + label_styles: crate::graph::LabelStyles { legend: Some(crate::CharStyle { fill: red, ..Default::default() }), ..Default::default() }, + ..Default::default() + }; + assert!(spec.label_styles.legend_fill_is_own() && !spec.label_styles.legend_fill_set, "not black, saved without the mark"); + d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec)); + let mut to_black = |c: &mut Color, s: &mut Option<String>, _: &mut f32| { + s.as_deref() == Some("Brand") && { + *c = Color::BLACK; + *s = None; + true + } + }; + assert_eq!(d.map_solid_paints(&mut to_black), 2, "the rectangle's fill and the legend fill"); + let styles = d.node(a).unwrap().extras().graph.clone().unwrap().label_styles; + assert_eq!(styles.legend.unwrap().fill, Paint::solid(Color::BLACK)); + assert!(styles.legend_fill_set, "the black fill is still the labels' own"); + } + + #[test] + fn the_default_black_legend_fill_is_left_as_it_is() { + let (mut d, a, _) = doc(); + let black = || crate::CharStyle { fill: Paint::solid(Color::BLACK), size: 11.0, ..Default::default() }; + let spec = crate::graph::GraphSpec { + series_paints: vec![crate::SeriesPaint { fill: Some(Paint::solid(Color::BLACK)), ..Default::default() }], + label_styles: crate::graph::LabelStyles { values: Some(black()), legend: Some(black()), ..Default::default() }, + ..Default::default() + }; + let blacks = |d: &Document| { + let mut seen = 0; + d.visit_paints(&mut |p| seen += usize::from(*p == Paint::solid(Color::BLACK))); + seen + }; + let before = blacks(&d); + d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec)); + assert_eq!(blacks(&d) - before, 2, "the series fill and the value labels' fill are visited"); + let mut link_black = |c: &mut Color, s: &mut Option<String>, _: &mut f32| *c == Color::BLACK && s.replace("Black".into()).is_none(); + assert_eq!(d.map_solid_paints_in(&[a], &mut link_black), 2, "the series fill and the value labels' fill"); + let styles = d.node(a).unwrap().extras().graph.clone().unwrap().label_styles; + assert_eq!(styles.legend.unwrap().fill, Paint::solid(Color::BLACK), "not linked"); + assert_eq!(styles.values.unwrap().fill, linked(Color::BLACK, "Black")); + } + #[test] fn mapping_within_ids_visits_each_paint_once() { let (mut d, a, b) = doc(); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 66295f50f2..0b617053b0 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -662,8 +662,15 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color } } let styles = &mut g.label_styles; - for st in [&mut styles.values, &mut styles.categories, &mut styles.legend].into_iter().flatten() { - map_paint_colors(&mut st.fill, f); + // A legend fill of the labels' own stays theirs when `f` makes it black. The default black legend fill stays black, + // so the labels on dark pie wedges are still drawn white. + styles.mark_own_legend_fill(); + let own = styles.legend_fill_is_own(); + for (st, fill) in [(&mut styles.values, true), (&mut styles.categories, true), (&mut styles.legend, own)] { + let Some(st) = st else { continue }; + if fill { + map_paint_colors(&mut st.fill, f); + } map_paint_colors(&mut st.stroke, f); } for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() { diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index b5acb1ba66..21dc2ea167 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -631,19 +631,33 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: & changed } -/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether -/// the kept style changed. +/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). A `change` that +/// sets the fill of legend labels marks it as set on them, so a black one stays black on dark pie wedges. Returns whether +/// the kept style or that mark changed. fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false }; let mut style = label_style(&spec.label_styles, role); + // The kept fill can already be the one `change` sets (black, the default): `change` sets the fill when it gives one + // of two different fills something else. + let sets_fill = role == Role::Legend + && [Paint::None, Paint::solid(Color::BLACK)].into_iter().any(|fill| { + let mut probe = CharStyle { fill: fill.clone(), ..style.clone() }; + change(&mut probe); + probe.fill != fill + }); change(&mut style); + let styles = &mut spec.label_styles; let slot = match role { - Role::Value => &mut spec.label_styles.values, - Role::Category => &mut spec.label_styles.categories, - Role::Legend => &mut spec.label_styles.legend, + Role::Value => &mut styles.values, + Role::Category => &mut styles.categories, + Role::Legend => &mut styles.legend, }; - let changed = slot.as_ref() != Some(&style); + let mut changed = slot.as_ref() != Some(&style); *slot = Some(style); + if sets_fill && !styles.legend_fill_set { + styles.legend_fill_set = true; + changed = true; + } changed } @@ -872,12 +886,9 @@ fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Ve } /// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with -/// Legends in Wedges whose legend labels keep no fill of their own? +/// Legends in Wedges whose legend labels keep no fill of their own ([`vectorcraft_doc::LabelStyles::legend_fill_is_own`])? pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool { - g.kind == GraphKind::Pie - && g.legend - && g.pie_legend_in_wedges - && !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)) + g.kind == GraphKind::Pie && g.legend && g.pie_legend_in_wedges && !g.label_styles.legend_fill_is_own() } /// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index fd73168503..17b4a2d1d9 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -6260,6 +6260,72 @@ fn pie_names_and_wedge_labels_keep_their_style() { assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge"); } +/// On a pie with Legends in Wedges, a black fill given to the white legend label on the dark wedge is kept for every +/// legend label: the graph draws it black on the dark wedge again, also after a native save and open. +#[test] +fn a_black_fill_set_on_a_wedge_legend_label_stays_black_on_dark_wedges() { + let cases = [("paint.setFill", json!({"color": "#000000"})), ("text.setStyle", json!({"fill": "#000000"})), ("edit.colors.invert", json!({}))]; + every_case(cases, |(cmd, params)| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nP1,1,2"); + s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); + let legend_fill = |s: &Session, text: &str| { + let NodeKind::Text(t) = label_node(s, id, "Legend", text).kind else { panic!("{text} is not type") }; + t.runs[0].style.fill.color().map(|c| c.to_hex()).unwrap_or_default() + }; + assert_eq!(legend_fill(&s, "a"), "#ffffff", "white on the black wedge"); + let mut p = params.clone(); + p["ids"] = json!([text_id(&s, id, "Legend", "a").0]); + s.execute(cmd, &p).unwrap(); + assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!spec(&s, id).label_styles.legend_fill_set, "{cmd}: undone"); + assert_eq!(legend_fill(&s, "a"), "#ffffff", "{cmd}: white again after the undo"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}: redone"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap(); + assert_eq!((legend_fill(&s, "a"), legend_fill(&s, "b")), ("#000000".into(), "#000000".into()), "{cmd}: black on both wedges"); + let dir = vectorcraft_testkit::TempDir::new("graph-wedge-legend"); + let path = dir.join("pie.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}: kept through save and open"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 1]]})).unwrap(); + assert_eq!(legend_fill(&s, "a"), "#000000", "{cmd}: still black after open"); + }); +} + +/// A size given to the legend labels of a pie with Legends in Wedges keeps the default black fill with it. A color mode +/// change leaves that fill black, so the legend label on the dark wedge is still drawn white. A legend fill that is not +/// black, in a file saved without the mark, is marked as set by the change. +#[test] +fn a_color_mode_change_keeps_the_default_wedge_legend_fill() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nP1,1,2"); + s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 11})).unwrap(); + s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); + let styles = spec(&s, id).label_styles; + assert!(!styles.legend_fill_set && !styles.legend_fill_is_own(), "{styles:?}"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap(); + let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") }; + assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#ffffff".into()), "white on the black wedge"); + let red = Paint::solid(vectorcraft_color::Color::from_hex("#ff0000").unwrap()); + let styles = vectorcraft_doc::LabelStyles { legend: Some(vectorcraft_doc::CharStyle { fill: red, ..Default::default() }), ..Default::default() }; + assert!(!styles.legend_fill_set && styles.legend_fill_is_own(), "{styles:?}"); + s.edit("legacy", |d, _| { + d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles; + Ok(()) + }) + .unwrap(); + s.execute("file.documentColorMode", &json!({"mode": "rgb"})).unwrap(); + assert!(spec(&s, id).label_styles.legend_fill_set, "the converted red is still the labels' own"); +} + #[test] fn labels_are_told_apart_by_where_they_come_not_by_their_text() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 4ebcdfe7dc..dfe109e9d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -849,7 +849,8 @@ stored without its placement (its vector, aspect ratio, focal point and freeform own box when the graph is drawn again, so `paint.setGradientGeom` and the `paint.freeform.*` edits do not store the placement they set there. A stroke weight and dashes are stored as the label shows them, also on a label that was scaled. When the characters of labels of one kind change differently, the frontmost label sets what the kind keeps. On -a pie with Legends in Wedges whose legend labels have no fill of their own, the legend labels' fill is stored only when +a pie with Legends in Wedges, a fill a command sets on the legend labels is drawn on every wedge, black included, until +that command is undone; a size, font or stroke change does not set one. Before that, their fill is stored only when every legend label the command reaches ends up with the same fill. Otherwise they are drawn again in black, or white on a dark wedge. These commands do not store the colors they give a graph's axis lines, ticks and radar rings, which are drawn again with their default paint. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f2f6fbcb02..7bb57903e7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1132,7 +1132,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, or recolored, given Swap Fill and Stroke or Default Fill and Stroke, or given stroke options or a gradient, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, or recolored, given Swap Fill and Stroke or Default Fill and Stroke, or given stroke options or a gradient, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. On a pie with Legends in Wedges, the legend labels are drawn black, or white on a dark wedge, until a command sets their fill, which is then drawn on every wedge, black included, and kept through save and open. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. From 134b8b33a1ecff129705c3995d764125f55550fa Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 02:59:02 -0800 Subject: [PATCH 37/89] Export: placed symbol art keeps its instance's link An instance whose symbol art has live effects is replaced in the export by a group holding the baked art. That group now takes the instance's attributes as well, so a PDF export keeps the instance's URL as a link annotation over the art where its effects draw it. --- crates/effects/src/bake.rs | 8 +++++--- crates/pdf/src/tests/links.rs | 25 +++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 12f967922d..f191f8ce89 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -241,8 +241,8 @@ fn holds_placed_symbol(n: &Node) -> bool { /// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it /// places, as the art it draws: the symbol's art with the instance's own appearance changes and /// its stain ([`vectorcraft_doc::symbol_overrides::instance_art`]), placed by the instance as the exporters -/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and -/// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an +/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask, +/// raster effects and attributes (its URL among them). The art is placed before its effects are baked, as the canvas draws it, so an /// instance that scales its art does not scale what its effects do. `None` for other objects, /// for instances the export does not write ([`unwritten`], hidden or inside such an object), for /// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art @@ -266,7 +266,9 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { return None; } let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; - Some(dressed(Node::group(n.id, vec![Arc::new(placed)]), n)) + let mut g = dressed(Node::group(n.id, vec![Arc::new(placed)]), n); + g.extras_mut().attrs = n.extras().attrs.clone(); + Some(g) } /// `n` with its geometry effects evaluated (`None` = unchanged). diff --git a/crates/pdf/src/tests/links.rs b/crates/pdf/src/tests/links.rs index c23854439f..f4792e7fa0 100644 --- a/crates/pdf/src/tests/links.rs +++ b/crates/pdf/src/tests/links.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{Array, Dict, Name}; use vectorcraft_color::Color; -use vectorcraft_doc::{Document, Node, NodeId}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, Rect, shapes}; use super::{add, doc, rect_node}; use crate::*; @@ -89,6 +89,27 @@ fn hidden_and_unprinted_objects_have_no_links_and_clipping_trims_them() { assert_eq!(links(&export(&d, &PdfOptions::default()).unwrap()), vec![Vec::<(String, [f32; 4])>::new()]); } +/// A linked symbol instance whose art has a live effect links over the art where the effect draws it. +#[test] +fn a_linked_instance_keeps_its_link_when_its_art_has_effects() { + let mut art = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(0.0, 0.0, 1.0)); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + let mut d = doc(200.0, 100.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + let mut instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY }); + instance.edit_attrs(|a| a.url = "https://example.com/symbol".into()); + add(&mut d, instance); + let pages = links(&export(&d, &PdfOptions::default()).unwrap()); + let [(uri, rect)] = pages[0].as_slice() else { panic!("one link: {pages:?}") }; + assert_eq!(uri, "https://example.com/symbol"); + // x 110…120 after the 100 pt move; y up: 100 − 50 to 100 − 40. + assert!(near(*rect, [110.0, 50.0, 120.0, 60.0]), "{rect:?}"); +} + #[test] fn each_page_links_its_own_artboard_objects() { let mut d = doc(200.0, 100.0); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 766f58d944..69a13ddc57 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1575,7 +1575,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A linked instance's PDF link covers its art where the effects draw it. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`, `a_linked_instance_keeps_its_link_when_its_art_has_effects`). A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). From cde9a27b4f775cde1eae016e7eb8d6e58a6cce2b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 02:56:51 -0800 Subject: [PATCH 38/89] Objects on Path: the widgets flatten the group's path once per lookup Finding where the Rotate All, Space, Move All and Select/Move widgets go and testing whether the pointer is over one flattened the group's path two to three times per widget. Starting a Space, Move All or Select/Move drag flattened it five times, and one pointer move over the group could flatten it eleven times. Each lookup now flattens the path once and shares that measurement across the widgets: - vectorcraft-doc makes `Measured` public with `stop` and `stops`, and adds `attachable`, which returns the path with its measurement. - `Widgets::of` gives all four widgets for the canvas, `widget_at` gives the widget under the pointer, and `WidgetDrag::hit` gives the drag a press starts, each from one flattening. The Selection and Objects on Path tools and the canvas use them. - A Space, Move All or Select/Move drag keeps its measurement and measures again only when the group's path changes. Widget places, hits, cursors and drag results are unchanged. The new `spine-count` feature of vectorcraft-doc exposes the flattening counter to vectorcraft-tools' tests; it is enabled only as a dev-dependency and leaves Cargo.lock unchanged. --- crates/doc/Cargo.toml | 4 + crates/doc/src/blend.rs | 12 +- crates/doc/src/objects_on_path.rs | 51 ++- crates/tools/Cargo.toml | 2 + crates/tools/src/objects_on_path.rs | 534 ++++++++++++++++++++------ crates/tools/src/select.rs | 38 +- crates/ui-egui/src/canvas/overlays.rs | 29 +- docs/target-app-parity.md | 2 + 8 files changed, 506 insertions(+), 166 deletions(-) diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 0ee88bf3b7..3a5626054b 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -8,6 +8,10 @@ description = "VectorCraft document model: artboards, layers, objects, appearanc [lints] workspace = true +[features] +# `vectorcraft_doc::blend::spines_built` for other crates' tests (dev-dependencies only). +spine-count = [] + [dependencies] vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index b84de1cac3..23bb7e7b3f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1205,13 +1205,21 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> // Spine // ===================================================================================== -#[cfg(test)] +#[cfg(any(test, feature = "spine-count"))] thread_local! { /// How many times [`Spine::new`] has run on this thread, for tests that count how often a path /// is flattened. pub(crate) static SPINES_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; } +/// How many times [`Spine::new`] has run on this thread, for other crates' tests that count how +/// often a path is flattened (the `spine-count` feature, dev-dependencies only). +#[cfg(any(test, feature = "spine-count"))] +#[doc(hidden)] +pub fn spines_built() -> usize { + SPINES_BUILT.with(|n| n.get()) +} + /// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the /// arc length at each of its anchors. pub struct Spine { @@ -1230,7 +1238,7 @@ impl Spine { /// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when /// the first one flattens to fewer than two points (so it has no length to walk). pub fn new(path: &PathData) -> Option<Self> { - #[cfg(test)] + #[cfg(any(test, feature = "spine-count"))] SPINES_BUILT.with(|n| n.set(n.get() + 1)); let sp = path.subpaths.first()?; let mut pts: Vec<Point> = Vec::new(); diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 35de7cce73..7362f324ea 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -234,8 +234,14 @@ pub fn is_objects_on_path(n: &Node) -> bool { /// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path /// (not a guide) whose first subpath has a length ([`path_length`]). pub fn attachable_path(n: &Node) -> Option<&PathData> { + attachable(n).map(|(path, _)| path) +} + +/// [`attachable_path`] with the path measured ([`Measured`]), from the one flattening that checks +/// its length. +pub fn attachable(n: &Node) -> Option<(&PathData, Measured)> { match &n.kind { - NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path), + NodeKind::Path { path, guide: false, .. } => Some((path, Measured::new(path)?)), _ => None, } } @@ -301,20 +307,51 @@ pub fn path_length(path: &PathData) -> Option<(f64, bool)> { /// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that /// one flattening serves both measuring the path and finding points along it. -struct Measured { +pub struct Measured { spine: Spine, len: f64, closed: bool, } +impl std::fmt::Debug for Measured { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Measured").field("len", &self.len).field("closed", &self.closed).finish_non_exhaustive() + } +} + impl Measured { /// `None` for a path without length, or with a length too large to be finite. - fn new(path: &PathData) -> Option<Self> { + pub fn new(path: &PathData) -> Option<Self> { let spine = Spine::new(path)?; let len = spine.length(); let closed = path.subpaths.first().is_some_and(|s| s.closed); (len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed }) } + /// The flattened first subpath, to measure along. + pub fn spine(&self) -> &Spine { + &self.spine + } + /// The length of the first subpath (points), as [`path_length`] gives it. + pub fn length(&self) -> f64 { + self.len + } + /// Is the first subpath closed? + pub fn closed(&self) -> bool { + self.closed + } + /// [`stops`] on this path. + pub fn stops(&self, n: usize, spacing: Option<f64>, start: f64) -> Vec<(Point, f64)> { + let (start, gap) = layout(self.len, self.closed, n, spacing, start); + (0..n).map(|i| self.spine.at_length(nth_length(self.len, self.closed, start, gap, i))).collect() + } + /// [`stop`] on this path: `None` for an `i` of `n` or more. + pub fn stop(&self, n: usize, spacing: Option<f64>, start: f64, i: usize) -> Option<(Point, f64)> { + if i >= n { + return None; + } + let (start, gap) = layout(self.len, self.closed, n, spacing, start); + Some(self.spine.at_length(nth_length(self.len, self.closed, start, gap, i))) + } } /// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the @@ -359,9 +396,7 @@ fn wrap(s: f64, len: f64) -> f64 { /// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them /// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length. pub fn stops(path: &PathData, n: usize, spacing: Option<f64>, start: f64) -> Option<Vec<(Point, f64)>> { - let Measured { spine, len, closed } = Measured::new(path)?; - let (start, gap) = layout(len, closed, n, spacing, start); - Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect()) + Some(Measured::new(path)?.stops(n, spacing, start)) } /// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others. @@ -370,9 +405,7 @@ pub fn stop(path: &PathData, n: usize, spacing: Option<f64>, start: f64, i: usiz if i >= n { return None; } - let Measured { spine, len, closed } = Measured::new(path)?; - let (start, gap) = layout(len, closed, n, spacing, start); - Some(spine.at_length(nth_length(len, closed, start, gap, i))) + Measured::new(path)?.stop(n, spacing, start, i) } /// The distance along a path of length `len` (closed or not) to object `i`, from `start` and diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 042a6f5174..269b7365d1 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -24,3 +24,5 @@ serde_json = { workspace = true } image = { workspace = true } # The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine). vectorcraft-text = { workspace = true, features = ["test-fonts", "bundled-fonts"] } +# How many times a path is flattened. +vectorcraft-doc = { workspace = true, features = ["spine-count"] } diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index 8f19259126..fe956691e1 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -42,13 +42,14 @@ //! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>, //! <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html>. +use std::sync::Arc; + use serde_json::{Value, json}; -use vectorcraft_doc::blend::Spine; use vectorcraft_doc::objects_on_path::{ - CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops, + CENTER, Measured, Member, ObjectsOnPath, attachable, attachable_path, is_objects_on_path, layout, nth_length, }; use vectorcraft_doc::{Document, Node, NodeId, Selection}; -use vectorcraft_geom::{BezPath, Point, Vec2}; +use vectorcraft_geom::{BezPath, PathData, Point, Vec2}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -85,14 +86,20 @@ pub const MEMBER_RADIUS_PX: f32 = 4.5; /// How near (screen px) the pointer must be to hit a Select/Move widget. const MEMBER_HIT_PX: f64 = 6.0; -/// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with -/// its record, its children and its path: only when the selection is that one editable group, on -/// a path with length, and no anchors are Direct-Selected. -fn one_group<'a>( - doc: &'a Document, - selection: &Selection, - zoom: f64, -) -> Option<(NodeId, &'a ObjectsOnPath, &'a [std::sync::Arc<vectorcraft_doc::Node>], &'a vectorcraft_geom::PathData)> { +/// An Objects on Path group the widgets show for, with its record, its children, its path and the +/// path measured: one flattening serves every widget of the group. +struct Group<'a> { + id: NodeId, + rec: &'a ObjectsOnPath, + children: &'a [Arc<Node>], + path: &'a PathData, + measured: Arc<Measured>, +} + +/// The Objects on Path group the widgets show for at `zoom` (screen px per document point): only +/// when the selection is that one editable group, on a path with length, and no anchors are +/// Direct-Selected. +fn one_group<'a>(doc: &'a Document, selection: &Selection, zoom: f64) -> Option<Group<'a>> { if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 { return None; } @@ -103,8 +110,39 @@ fn one_group<'a>( let n = doc.node(*group)?; let rec = n.extras().objects_on_path.as_deref()?; let children = n.children()?; - let path = attachable_path(children.iter().find(|c| c.id == rec.path)?)?; - Some((*group, rec, children.as_slice(), path)) + let (path, measured) = attachable(children.iter().find(|c| c.id == rec.path)?)?; + Some(Group { id: *group, rec, children: children.as_slice(), path, measured: Arc::new(measured) }) +} + +/// The path of an Objects on Path group as measured when a widget drag began, kept for the drag: +/// the previews change the group's record and its objects, not its path. +#[derive(Clone, Debug)] +struct Kept { + group: NodeId, + path: PathData, + measured: Arc<Measured>, +} + +impl Kept { + fn of(g: &Group) -> Self { + Self { group: g.id, path: g.path.clone(), measured: Arc::clone(&g.measured) } + } + + /// The group's path in `doc` measured: the kept measurement while the path is as it was, else + /// the path measured again and kept. `None` when the group no longer has a path with length. + fn now(&mut self, doc: &Document) -> Option<Arc<Measured>> { + let n = doc.node(self.group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let node = n.children()?.iter().find(|c| c.id == rec.path)?; + if let vectorcraft_doc::NodeKind::Path { path, guide: false, .. } = &node.kind + && *path == self.path + { + return Some(Arc::clone(&self.measured)); + } + let (path, measured) = attachable(node)?; + *self = Self { group: self.group, path: path.clone(), measured: Arc::new(measured) }; + Some(Arc::clone(&self.measured)) + } } /// The direction the top of the object `m` faces: the -y direction of the frame it is drawn in @@ -148,22 +186,27 @@ impl RotateAllWidget { /// selection is one editable Objects on Path group with at least one object on a path with /// length, and no anchors are Direct-Selected. pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> { - let (group, rec, children, path) = one_group(doc, selection, zoom)?; - let first = rec.members.first()?; - let (pivot, _) = stop(path, rec.members.len(), rec.spacing, rec.start, 0)?; - let at = beside(doc, children, first, pivot, top(first), ROTATE_ALL_OFFSET_PX / zoom)?; - Some(Self { group, pivot, at, rotate: rec.rotate }) + Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom) } - /// The widget of `cx` when `p` is within reach of it. - fn at_point(cx: &ToolContext, p: Point) -> Option<Self> { - Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(ROTATE_ALL_HIT_PX)) + /// The widget of the group `g`. + fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option<Self> { + let rec = g.rec; + let first = rec.members.first()?; + let (pivot, _) = g.measured.stop(rec.members.len(), rec.spacing, rec.start, 0)?; + let at = beside(doc, g.children, first, pivot, top(first), ROTATE_ALL_OFFSET_PX / zoom)?; + Some(Self { group: g.id, pivot, at, rotate: rec.rotate }) + } + + /// The widget of the group `g` of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option<Self> { + Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(ROTATE_ALL_HIT_PX)) } } /// Is `p` over the Rotate All widget? pub fn over_rotate_all(cx: &ToolContext, p: Point) -> bool { - RotateAllWidget::at_point(cx, p).is_some() + one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| RotateAllWidget::at_point(cx, &g, p)).is_some() } /// Dragging the Rotate All widget: the group's Rotate follows the angle the pointer turns about @@ -191,7 +234,12 @@ impl RotateAllDrag { /// Start a drag when the press `ev` is on the Rotate All widget. pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> { - let widget = RotateAllWidget::at_point(cx, ev.pos)?; + Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev) + } + + /// [`RotateAllDrag::hit`] on the group `g` of `cx`. + fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option<Self> { + let widget = RotateAllWidget::at_point(cx, g, ev.pos)?; let d = ev.pos - widget.pivot; let last = d.y.atan2(d.x); let last = if last.is_finite() { last } else { 0.0 }; @@ -272,17 +320,22 @@ impl SpaceWidget { /// length, and no anchors are Direct-Selected. It sits beyond the last object's bounds, ahead /// along the path's direction there. pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> { - let (group, rec, children, path) = one_group(doc, selection, zoom)?; + Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom) + } + + /// The widget of the group `g`. + fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option<Self> { + let (group, rec) = (g.id, g.rec); let count = rec.members.len(); if count < 2 { return None; } - let (len, closed) = path_length(path)?; + let (len, closed) = (g.measured.length(), g.measured.closed()); let (start, gap) = layout(len, closed, count, rec.spacing, rec.start); - let (last, angle) = stop(path, count, rec.spacing, rec.start, count - 1)?; + let (last, angle) = g.measured.stop(count, rec.spacing, rec.start, count - 1)?; let dir = Vec2::new(angle.cos(), angle.sin()); let m = rec.members.last()?; - let b = children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?; + let b = g.children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?; let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; let reach = corners.iter().map(|c| (*c - last).dot(dir)).fold(0.0, f64::max); let at = last + dir * (reach + SPACE_OFFSET_PX / zoom); @@ -291,29 +344,26 @@ impl SpaceWidget { finite.then_some(Self { group, last, last_length, at, dir, len, closed, count, start, gap, saved: (rec.spacing, rec.start) }) } - /// The widget of `cx` when `p` is within reach of it. - fn at_point(cx: &ToolContext, p: Point) -> Option<Self> { - Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(SPACE_HIT_PX)) + /// The widget of the group `g` of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option<Self> { + Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(SPACE_HIT_PX)) } } /// Is `p` over the Space widget? pub fn over_space(cx: &ToolContext, p: Point) -> bool { - SpaceWidget::at_point(cx, p).is_some() + one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| SpaceWidget::at_point(cx, &g, p)).is_some() } -/// The distance along the path of the Objects on Path group of `w` in `doc` to the point nearest -/// `p` ([`Spine::nearest`]: past the ends of an open path, along the lines of its end segments), -/// staying near `from` when the pointer is following the path from there. The line from the last -/// object's attachment point through the widget counts as the path too, as far as the pointer is -/// ahead of that object along it, so a widget beyond a corner measures from where it is. The flag -/// says whether the point is on that line, where the distance runs on past the end of a closed path -/// instead of round it. -fn length_near(doc: &Document, w: &SpaceWidget, p: Point, from: Option<f64>) -> Option<(f64, bool)> { - let n = doc.node(w.group)?; - let rec = n.extras().objects_on_path.as_deref()?; - let path = attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?; - let spine = Spine::new(path)?; +/// The distance along the path of the Objects on Path group of `w`, `measured`, to the point nearest +/// `p` ([`vectorcraft_doc::blend::Spine::nearest`]: past the ends of an open path, along the lines +/// of its end segments), staying near `from` when the pointer is following the path from there. The +/// line from the last object's attachment point through the widget counts as the path too, as far +/// as the pointer is ahead of that object along it, so a widget beyond a corner measures from where +/// it is. The flag says whether the point is on that line, where the distance runs on past the end +/// of a closed path instead of round it. +fn length_near(measured: &Measured, w: &SpaceWidget, p: Point, from: Option<f64>) -> Option<(f64, bool)> { + let spine = measured.spine(); let bias = |s: f64| from.filter(|f| f.is_finite()).map_or(0.0, |f| spine.apart(s, f) / 2.0); let ahead = (p - w.last).dot(w.dir); let line = (ahead >= 0.0).then(|| { @@ -334,6 +384,8 @@ fn length_near(doc: &Document, w: &SpaceWidget, p: Point, from: Option<f64>) -> #[derive(Clone, Debug)] pub struct SpaceDrag { widget: SpaceWidget, + /// The group's path, measured. + path: Kept, start: Point, began: bool, /// The distance along the path to the point nearest the pointer at the last drag (points). @@ -353,17 +405,23 @@ impl SpaceDrag { /// Start a drag when the press `ev` is on the Space widget. pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> { - let widget = SpaceWidget::at_point(cx, ev.pos)?; - let (last, _) = length_near(cx.doc, &widget, ev.pos, None)?; - Some(Self { widget, start: ev.pos, began: false, last, along: 0.0, gap: widget.gap, at: ev.pos }) + Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev) + } + + /// [`SpaceDrag::hit`] on the group `g` of `cx`. + fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option<Self> { + let widget = SpaceWidget::at_point(cx, g, ev.pos)?; + let (last, _) = length_near(&g.measured, &widget, ev.pos, None)?; + Some(Self { widget, path: Kept::of(g), start: ev.pos, began: false, last, along: 0.0, gap: widget.gap, at: ev.pos }) } /// The pointer moved to `p` from `self.at`: add the distance it moved along the path. On the /// line through the widget, from a point ahead of the last object, that is how far it moved /// along the line; otherwise it takes the shorter way round a closed path. fn follow(&mut self, doc: &Document, p: Point) { + let Some(measured) = self.path.now(doc) else { return }; let w = &self.widget; - let Some((t, on_line)) = length_near(doc, w, p, Some(self.last)) else { return }; + let Some((t, on_line)) = length_near(&measured, w, p, Some(self.last)) else { return }; let before = (self.at - w.last).dot(w.dir); let step = if on_line && before >= 0.0 { (p - w.last).dot(w.dir) - before @@ -469,10 +527,15 @@ impl MoveAllWidget { /// the distance from the first object to the last is shorter than the path. It sits beyond the /// first object's bounds, on the side its bottom faces. pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> { - let (group, rec, children, path) = one_group(doc, selection, zoom)?; + Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom) + } + + /// The widget of the group `g`. + fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option<Self> { + let (group, rec, children) = (g.id, g.rec, g.children); let count = rec.members.len(); let first = rec.members.first()?; - let (len, closed) = path_length(path)?; + let (len, closed) = (g.measured.length(), g.measured.closed()); let (start, gap) = layout(len, closed, count, rec.spacing, rec.start); let most = if closed { len } else { len - gap * count.saturating_sub(1) as f64 }; if !closed && (!most.is_finite() || most <= 1e-9) { @@ -481,21 +544,21 @@ impl MoveAllWidget { // On an open path, Auto and a Space value the path is too short for draw the objects apart // by what is left of the path after the start, so another start would change the distance. let keeps = closed || count < 2 || rec.spacing.is_some_and(|g| (g - gap).abs() < 1e-9); - let (at_first, _) = stop(path, count, rec.spacing, rec.start, 0)?; + let (at_first, _) = g.measured.stop(count, rec.spacing, rec.start, 0)?; let at = beside(doc, children, first, at_first, -top(first), MOVE_ALL_OFFSET_PX / zoom)?; let finite = [len, start, most, rec.start].iter().all(|v| v.is_finite()); finite.then_some(Self { group, first: at_first, at, len, closed, start, most, gap: (!keeps).then_some(gap), saved: rec.start }) } - /// The widget of `cx` when `p` is within reach of it. - fn at_point(cx: &ToolContext, p: Point) -> Option<Self> { - Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(MOVE_ALL_HIT_PX)) + /// The widget of the group `g` of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option<Self> { + Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(MOVE_ALL_HIT_PX)) } } /// Is `p` over the Move All widget? pub fn over_move_all(cx: &ToolContext, p: Point) -> bool { - MoveAllWidget::at_point(cx, p).is_some() + one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| MoveAllWidget::at_point(cx, &g, p)).is_some() } /// Dragging the Move All widget: the distance the pointer moves along the path moves every object @@ -504,6 +567,8 @@ pub fn over_move_all(cx: &ToolContext, p: Point) -> bool { #[derive(Clone, Debug)] pub struct MoveAllDrag { widget: MoveAllWidget, + /// The group's path, measured. + path: Kept, start: Point, began: bool, /// The distance along the path to the point nearest the pointer at the last drag (points). @@ -521,9 +586,14 @@ impl MoveAllDrag { /// Start a drag when the press `ev` is on the Move All widget. pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> { - let widget = MoveAllWidget::at_point(cx, ev.pos)?; - let (_, last) = group_spine(cx.doc, widget.group)?.nearest(ev.pos, Some(widget.start))?; - last.is_finite().then_some(Self { widget, start: ev.pos, began: false, last, to: widget.start, at: ev.pos }) + Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev) + } + + /// [`MoveAllDrag::hit`] on the group `g` of `cx`. + fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option<Self> { + let widget = MoveAllWidget::at_point(cx, g, ev.pos)?; + let (_, last) = g.measured.spine().nearest(ev.pos, Some(widget.start))?; + last.is_finite().then_some(Self { widget, path: Kept::of(g), start: ev.pos, began: false, last, to: widget.start, at: ev.pos }) } /// The pointer moved to `p`: move the first object as far along the path as the point nearest @@ -531,8 +601,8 @@ impl MoveAllDrag { /// the first anchor and [`MoveAllWidget::most`], so moving back from beyond an end moves the /// objects back at once. fn follow(&mut self, doc: &Document, p: Point) { + let Some((_, t)) = self.path.now(doc).and_then(|m| m.spine().nearest(p, Some(self.last))) else { return }; let w = &self.widget; - let Some((_, t)) = group_spine(doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return }; let step = if w.closed { (t - self.last + w.len / 2.0).rem_euclid(w.len) - w.len / 2.0 } else { t - self.last }; if !t.is_finite() || !step.is_finite() { return; @@ -604,8 +674,13 @@ impl MemberWidget { /// its order along the path that is neither hidden nor locked: only when the selection is one /// editable Objects on Path group on a path with length, and no anchors are Direct-Selected. pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec<Self> { - let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] }; - let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] }; + one_group(doc, selection, zoom).map(|g| Self::in_group(doc, selection, &g)).unwrap_or_default() + } + + /// The widgets of the group `g`, the selection. + fn in_group(doc: &Document, selection: &Selection, g: &Group) -> Vec<Self> { + let (group, rec) = (g.id, g.rec); + let at = g.measured.stops(rec.members.len(), rec.spacing, rec.start); let editable = doc.editable(); rec.members .iter() @@ -616,17 +691,16 @@ impl MemberWidget { .collect() } - /// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the - /// chosen one, else the first along the path. - fn at_point(cx: &ToolContext, p: Point) -> Option<Self> { + /// The widget of the group `g` of `cx` nearest `p`, when `p` is within reach of it; of widgets + /// as near, the chosen one, else the first along the path. + fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option<Self> { let reach = cx.tol(MEMBER_HIT_PX); // Every attachment point lies on the path: a pointer beyond reach of its bounds is over none. - let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?; - if !path.control_bounds()?.inflate(reach, reach).contains(p) { + if !g.path.control_bounds()?.inflate(reach, reach).contains(p) { return None; } let mut best: Option<(f64, Self)> = None; - for w in Self::all(cx.doc, cx.selection, cx.zoom) { + for w in Self::in_group(cx.doc, cx.selection, g) { let d = w.at.distance(p); let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen)); if d <= reach && nearer { @@ -639,17 +713,87 @@ impl MemberWidget { /// Is `p` over a Select/Move widget? pub fn over_member(cx: &ToolContext, p: Point) -> bool { - MemberWidget::at_point(cx, p).is_some() + one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| MemberWidget::at_point(cx, &g, p)).is_some() } /// The highlight of the Select/Move widget under `p`, if any: a ring around it. pub fn member_hover(cx: &ToolContext, p: Point) -> Option<Overlay> { - let w = MemberWidget::at_point(cx, p)?; + let w = MemberWidget::at_point(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, p)?; let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0); let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r)); Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false }) } +/// The widgets of the selection at `zoom` (screen px per document point), each as its own `of` or +/// `all` gives it, from one measurement of the group's path: for drawing them together. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Widgets { + pub members: Vec<MemberWidget>, + pub rotate_all: Option<RotateAllWidget>, + pub space: Option<SpaceWidget>, + pub move_all: Option<MoveAllWidget>, +} + +impl Widgets { + pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Self { + let Some(g) = one_group(doc, selection, zoom) else { return Self::default() }; + Self { + members: MemberWidget::in_group(doc, selection, &g), + rotate_all: RotateAllWidget::in_group(doc, &g, zoom), + space: SpaceWidget::in_group(doc, &g, zoom), + move_all: MoveAllWidget::in_group(doc, &g, zoom), + } + } +} + +/// A widget of a selected Objects on Path group. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WidgetKind { + RotateAll, + Space, + MoveAll, + Member, +} + +/// The widget `p` is over, of the Rotate All, Space, Move All and Select/Move widgets in that +/// order, from one measurement of the group's path: as [`over_rotate_all`], [`over_space`], +/// [`over_move_all`] and [`over_member`] answer in turn. +pub fn widget_at(cx: &ToolContext, p: Point) -> Option<WidgetKind> { + let g = one_group(cx.doc, cx.selection, cx.zoom)?; + if RotateAllWidget::at_point(cx, &g, p).is_some() { + Some(WidgetKind::RotateAll) + } else if SpaceWidget::at_point(cx, &g, p).is_some() { + Some(WidgetKind::Space) + } else if MoveAllWidget::at_point(cx, &g, p).is_some() { + Some(WidgetKind::MoveAll) + } else { + MemberWidget::at_point(cx, &g, p).map(|_| WidgetKind::Member) + } +} + +/// A drag of a widget of a selected Objects on Path group. +#[derive(Clone, Debug)] +pub enum WidgetDrag { + RotateAll(RotateAllDrag), + Space(SpaceDrag), + MoveAll(MoveAllDrag), + Member(MemberDrag), +} + +impl WidgetDrag { + /// The drag the press `ev` starts, of the Rotate All, Space, Move All and Select/Move widgets + /// in that order, from one measurement of the group's path: as [`RotateAllDrag::hit`], + /// [`SpaceDrag::hit`], [`MoveAllDrag::hit`] and [`MemberDrag::hit`] answer in turn. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> { + let g = one_group(cx.doc, cx.selection, cx.zoom)?; + RotateAllDrag::hit_in(cx, &g, ev) + .map(Self::RotateAll) + .or_else(|| SpaceDrag::hit_in(cx, &g, ev).map(Self::Space)) + .or_else(|| MoveAllDrag::hit_in(cx, &g, ev).map(Self::MoveAll)) + .or_else(|| MemberDrag::hit_in(cx, &g, ev).map(Self::Member)) + } +} + /// Dragging a Select/Move widget: the object goes to the place along the path nearest the /// pointer, and the objects between it and its place move one place toward where it was, except /// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond @@ -661,6 +805,8 @@ pub fn member_hover(cx: &ToolContext, p: Point) -> Option<Overlay> { #[derive(Clone, Debug)] pub struct MemberDrag { widget: MemberWidget, + /// The group's path, measured. + path: Kept, /// The objects in their order along the path when the press began. order: Vec<NodeId>, /// The distance along the path to each place (points), in that order. @@ -687,10 +833,14 @@ impl MemberDrag { /// Start a drag when the press `ev` is on a Select/Move widget. pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> { - let widget = MemberWidget::at_point(cx, ev.pos)?; - let n = cx.doc.node(widget.group)?; - let rec = n.extras().objects_on_path.as_deref()?; - let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?; + Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev) + } + + /// [`MemberDrag::hit`] on the group `g` of `cx`. + fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option<Self> { + let widget = MemberWidget::at_point(cx, g, ev.pos)?; + let rec = g.rec; + let (len, closed) = (g.measured.length(), g.measured.closed()); let count = rec.members.len(); let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start); let places: Vec<f64> = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect(); @@ -698,7 +848,7 @@ impl MemberDrag { let editable = cx.doc.editable(); let open = rec.members.iter().map(|m| editable(m.id)).collect(); let order = rec.members.iter().map(|m| m.id).collect(); - Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) + Some(Self { widget, path: Kept::of(g), order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) } /// How far apart along the path the distances `a` and `b` are: the shorter way round a closed @@ -733,8 +883,8 @@ impl MemberDrag { self.began = true; out.push(Action::Begin("Move Object on Path".into())); } - let w = &self.widget; - let Some(spine) = group_spine(cx.doc, w.group) else { return out }; + let Some(measured) = self.path.now(cx.doc) else { return out }; + let (w, spine) = (&self.widget, measured.spine()); let Some((near, t)) = spine.nearest(p, None) else { return out }; // Where the path passes as near the pointer more than once, the point nearest along the path // to where the drag was counts. @@ -767,13 +917,6 @@ impl MemberDrag { } } -/// The path of the Objects on Path group `g` of `doc`, to measure along. -fn group_spine(doc: &Document, g: NodeId) -> Option<Spine> { - let n = doc.node(g)?; - let rec = n.extras().objects_on_path.as_deref()?; - Spine::new(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?) -} - /// The path a click at `p` attaches the selected objects to: the path object hit there, when it can /// take objects, is in no Objects on Path group, is not a clipping path or part of a compound path, /// blend, envelope, repeat or Intertwine group ([`vectorcraft_doc::objects_on_path::held`]), and @@ -781,14 +924,13 @@ fn group_spine(doc: &Document, g: NodeId) -> Option<Spine> { pub fn target(cx: &ToolContext, p: Point) -> Option<(NodeId, BezPath)> { let h = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?; let n = cx.doc.node(h.leaf)?; - let path = attachable_path(n)?; if h.ancestry.iter().filter(|a| **a != h.leaf).any(|a| cx.doc.node(*a).is_some_and(is_objects_on_path)) { return None; } - if vectorcraft_doc::objects_on_path::held(cx.doc, h.leaf).is_some() { + if vectorcraft_doc::objects_on_path::held(cx.doc, h.leaf).is_some() || !cx.selection.objects.iter().any(|id| *id != h.leaf) { return None; } - cx.selection.objects.iter().any(|id| *id != h.leaf).then(|| (h.leaf, path.to_bezpath())) + attachable_path(n).map(|path| (h.leaf, path.to_bezpath())) } pub struct ObjectsOnPathTool { @@ -856,28 +998,17 @@ impl Tool for ObjectsOnPathTool { self.pointer = (ev.kind == PointerKind::Move).then_some(p); match ev.kind { PointerKind::Move => { - let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p); - self.hover = if widget { None } else { target(cx, p) }; + self.hover = if widget_at(cx, p).is_some() { None } else { target(cx, p) }; vec![] } PointerKind::Down => { - if let Some(d) = RotateAllDrag::hit(cx, ev) { - self.rotating = Some(d); - self.hover = None; - return vec![]; - } - if let Some(d) = SpaceDrag::hit(cx, ev) { - self.spacing = Some(d); - self.hover = None; - return vec![]; - } - if let Some(d) = MoveAllDrag::hit(cx, ev) { - self.moving = Some(d); - self.hover = None; - return vec![]; - } - if let Some(d) = MemberDrag::hit(cx, ev) { - self.member = Some(d); + if let Some(d) = WidgetDrag::hit(cx, ev) { + match d { + WidgetDrag::RotateAll(d) => self.rotating = Some(d), + WidgetDrag::Space(d) => self.spacing = Some(d), + WidgetDrag::MoveAll(d) => self.moving = Some(d), + WidgetDrag::Member(d) => self.member = Some(d), + } self.hover = None; return vec![]; } @@ -917,17 +1048,21 @@ impl Tool for ObjectsOnPathTool { if self.member.is_some() { return Cursor::Move; } - let idle = self.spacing.is_none() && self.moving.is_none(); - if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) { - Cursor::Rotate - } else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) { - Cursor::ArrowHollow - } else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) { - Cursor::Move - } else if target(cx, p).is_some() { - Cursor::ObjectsOnPath - } else { - Cursor::Arrow + if self.rotating.is_some() { + return Cursor::Rotate; + } + if self.spacing.is_some() { + return Cursor::ArrowHollow; + } + if self.moving.is_some() { + return Cursor::Move; + } + match widget_at(cx, p) { + Some(WidgetKind::RotateAll) => Cursor::Rotate, + Some(WidgetKind::Space) => Cursor::ArrowHollow, + Some(WidgetKind::MoveAll | WidgetKind::Member) => Cursor::Move, + None if target(cx, p).is_some() => Cursor::ObjectsOnPath, + None => Cursor::Arrow, } } @@ -1405,7 +1540,10 @@ mod tests { gap: 100.0, saved: (None, 0.0), }; - let moved = |along| SpaceDrag { widget: w, start: Point::ZERO, began: true, last: 0.0, along, gap: w.gap, at: Point::ZERO }; + let line = shapes::line(Point::ZERO, Point::new(400.0, 0.0)); + let path = Kept { group: NodeId(1), measured: Arc::new(Measured::new(&line).unwrap()), path: line }; + let moved = + |along| SpaceDrag { widget: w, path: path.clone(), start: Point::ZERO, began: true, last: 0.0, along, gap: w.gap, at: Point::ZERO }; assert_eq!(moved(30.0).spacing_at(false), (100.0, 0.0), "a quarter of the way round at most"); assert_eq!(moved(-60.0).spacing_at(false), (80.0, 0.0)); assert_eq!(moved(-60.0).spacing_at(true), (60.0, 310.0), "the span of 180 centered on the first anchor"); @@ -1958,4 +2096,168 @@ mod tests { t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one"); } + + /// What `f` returns, and how many times it flattened a path. + fn flattenings<T>(f: impl FnOnce() -> T) -> (T, usize) { + let before = vectorcraft_doc::blend::spines_built(); + let out = f(); + (out, vectorcraft_doc::blend::spines_built() - before) + } + + /// Space 100 from 50 on the line from (0, 300) to (400, 300): the objects attach at (50, 300) + /// and (150, 300). The Rotate All widget is at (50, 274), the Move All widget at (50, 326) and + /// the Space widget at (176, 300). + #[test] + fn each_widget_lookup_flattens_the_path_once() { + let (d, g, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + // Points to 1/1,000 pt, as the path is measured along its flattening. + let close = |a: Point, b: Point| a.distance(b) < 1e-3; + let (w, n) = flattenings(|| RotateAllWidget::of(&d, &sel, 1.0)); + assert_eq!(n, 1, "Rotate All"); + let w = w.unwrap(); + assert!(w.group == g && w.rotate == 0.0, "{w:?}"); + assert!(close(w.pivot, Point::new(50.0, 300.0)) && close(w.at, Point::new(50.0, 274.0)), "{w:?}"); + let (w, n) = flattenings(|| SpaceWidget::of(&d, &sel, 1.0)); + assert_eq!(n, 1, "Space"); + let w = w.unwrap(); + assert!(close(w.last, Point::new(150.0, 300.0)) && close(w.at, Point::new(176.0, 300.0)), "{w:?}"); + assert!((w.last_length - 150.0).abs() < 1e-3 && (w.len - 400.0).abs() < 1e-3 && !w.closed && w.count == 2, "{w:?}"); + assert!((w.start - 50.0).abs() < 1e-3 && (w.gap - 100.0).abs() < 1e-3 && w.saved == (Some(100.0), 50.0), "{w:?}"); + let (w, n) = flattenings(|| MoveAllWidget::of(&d, &sel, 1.0)); + assert_eq!(n, 1, "Move All"); + let w = w.unwrap(); + assert!(close(w.first, Point::new(50.0, 300.0)) && close(w.at, Point::new(50.0, 326.0)), "{w:?}"); + assert!((w.len - 400.0).abs() < 1e-3 && (w.start - 50.0).abs() < 1e-3 && (w.most - 300.0).abs() < 1e-3, "{w:?}"); + assert!(w.gap.is_none() && w.saved == 50.0 && !w.closed, "{w:?}"); + let (ws, n) = flattenings(|| MemberWidget::all(&d, &sel, 1.0)); + assert_eq!(n, 1, "Select/Move"); + assert_eq!(ws.iter().map(|w| w.index).collect::<Vec<_>>(), [0, 1]); + assert!(close(ws[0].at, Point::new(50.0, 300.0)) && close(ws[1].at, Point::new(150.0, 300.0)), "{ws:?}"); + // Each hit test, over its widget. + type Over = fn(&ToolContext, Point) -> bool; + let hits: [(&str, Over, Point); 4] = [ + ("Rotate All", over_rotate_all, Point::new(50.0, 274.0)), + ("Space", over_space, Point::new(176.0, 300.0)), + ("Move All", over_move_all, Point::new(50.0, 326.0)), + ("Select/Move", over_member, Point::new(150.0, 300.0)), + ]; + for (what, over, p) in hits { + assert_eq!(flattenings(|| over(&c, p)), (true, 1), "{what}"); + } + assert_eq!(flattenings(|| member_hover(&c, Point::new(150.0, 300.0)).is_some()), (true, 1)); + // A group of one object has no Space widget, and its objects no room to move on an open + // path: one flattening finds that. + let (d1, sel1) = single_doc(); + assert_eq!(flattenings(|| SpaceWidget::of(&d1, &sel1, 1.0)), (None, 1)); + let (d2, _, sel2) = group_doc(0.0); + assert_eq!(flattenings(|| MoveAllWidget::of(&d2, &sel2, 1.0)), (None, 1)); + // Nothing flattens for a selection the widgets do not show for. + assert_eq!(flattenings(|| Widgets::of(&d, &Selection::default(), 1.0)), (Widgets::default(), 0)); + } + + /// All four kinds of widget come from one flattening, as each one's own lookup gives it; and the + /// widget under the pointer, in the order a press takes them, from one flattening too. + #[test] + fn the_widgets_of_a_group_share_one_flattening() { + let (d, _, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let (all, n) = flattenings(|| Widgets::of(&d, &sel, 1.0)); + assert_eq!(n, 1); + assert_eq!(all.members, MemberWidget::all(&d, &sel, 1.0)); + assert_eq!(all.rotate_all, RotateAllWidget::of(&d, &sel, 1.0)); + assert_eq!(all.space, SpaceWidget::of(&d, &sel, 1.0)); + assert_eq!(all.move_all, MoveAllWidget::of(&d, &sel, 1.0)); + assert!(all.rotate_all.is_some() && all.space.is_some() && all.move_all.is_some() && all.members.len() == 2); + let cases = [ + (Point::new(50.0, 274.0), Some(WidgetKind::RotateAll)), + (Point::new(176.0, 300.0), Some(WidgetKind::Space)), + (Point::new(50.0, 326.0), Some(WidgetKind::MoveAll)), + (Point::new(150.0, 300.0), Some(WidgetKind::Member)), + // On the path between the objects: over none of them, after every one is tested. + (Point::new(250.0, 300.0), None), + ]; + for (p, kind) in cases { + assert_eq!(flattenings(|| widget_at(&c, p)), (kind, 1), "{p:?}"); + } + // A pointer move and the cursor of each tool look once. + for t in [Box::new(crate::select::SelectionTool::default()) as Box<dyn Tool>, Box::new(ObjectsOnPathTool::default())] { + for (p, cursor) in [(Point::new(50.0, 274.0), Cursor::Rotate), (Point::new(176.0, 300.0), Cursor::ArrowHollow)] { + assert_eq!(flattenings(|| t.cursor(&c, p, Mods::default())), (cursor, 1), "{}", t.id()); + } + } + let mut t = ObjectsOnPathTool::default(); + assert_eq!(flattenings(|| t.pointer(&c, &ev(PointerKind::Move, 250.0, 300.0))), (vec![], 1)); + } + + /// A press on each widget measures the path once, and the drag that follows measures it no more + /// while the path stays as it was. + #[test] + fn a_widget_drag_flattens_the_path_once() { + let (d, g, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let presses = [ + ("Rotate All", Point::new(50.0, 274.0), Point::new(24.0, 300.0)), + ("Space", Point::new(176.0, 300.0), Point::new(226.0, 300.0)), + ("Move All", Point::new(50.0, 326.0), Point::new(100.0, 326.0)), + ("Select/Move", Point::new(150.0, 300.0), Point::new(40.0, 300.0)), + ]; + let tools: [Box<dyn Fn() -> Box<dyn Tool>>; 2] = + [Box::new(|| Box::new(crate::select::SelectionTool::default())), Box::new(|| Box::new(ObjectsOnPathTool::default()))]; + for new in &tools { + for (what, at, to) in presses { + let mut t = new(); + assert_eq!(flattenings(|| t.pointer(&c, &ev(PointerKind::Down, at.x, at.y))), (vec![], 1), "{what} {}", t.id()); + assert!(t.busy(), "{what} {}", t.id()); + let (acts, n) = flattenings(|| t.pointer(&c, &ev(PointerKind::Drag, to.x, to.y))); + assert_eq!(n, 0, "{what} {}", t.id()); + assert!(matches!(acts.last(), Some(Action::Preview(..))), "{what} {}: {acts:?}", t.id()); + let (acts, n) = flattenings(|| t.pointer(&c, &ev(PointerKind::Drag, to.x + 1.0, to.y))); + assert_eq!(n, 0, "{what} {}: {acts:?}", t.id()); + } + } + // The drags as each one's own press starts it. + type Hit = fn(&ToolContext, &PointerEvent) -> bool; + let checks: [(&str, Hit, Point); 4] = [ + ("Rotate All", |c, e| RotateAllDrag::hit(c, e).is_some(), Point::new(50.0, 274.0)), + ("Space", |c, e| SpaceDrag::hit(c, e).is_some(), Point::new(176.0, 300.0)), + ("Move All", |c, e| MoveAllDrag::hit(c, e).is_some(), Point::new(50.0, 326.0)), + ("Select/Move", |c, e| MemberDrag::hit(c, e).is_some(), Point::new(150.0, 300.0)), + ]; + for (what, hit, p) in checks { + assert_eq!(flattenings(|| hit(&c, &ev(PointerKind::Down, p.x, p.y))), (true, 1), "{what}"); + } + // The Move All drag moves the objects by what the pointer moves along the path: 50 pt. + let mut d = MoveAllDrag::hit(&c, &ev(PointerKind::Down, 50.0, 326.0)).unwrap(); + let acts = d.drag(&c, Point::new(100.0, 326.0)); + assert!(matches!(acts.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{acts:?}"); + assert_eq!(moved(&acts), Some((100.0, None)), "{acts:?}"); + } + + /// A drag whose group's path changes while it goes on measures the new path, once. + #[test] + fn a_widget_drag_follows_a_path_that_changed() { + let (d, g, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut drag = MoveAllDrag::hit(&c, &ev(PointerKind::Down, 50.0, 326.0)).unwrap(); + // The path now runs from (100, 300) to (500, 300): the pointer at (200, 326) is 100 pt along + // it, 50 pt on from where the press was. + let mut longer = d.clone(); + let line = longer.node(g).and_then(|n| n.children()).unwrap()[0].id; + if let Some(NodeKind::Path { path, .. }) = longer.node_mut(line).map(|n| &mut n.kind) { + *path = shapes::line(Point::new(100.0, 300.0), Point::new(500.0, 300.0)); + } + let c = cx(&longer, &sel, &paint); + let (acts, n) = flattenings(|| drag.drag(&c, Point::new(200.0, 326.0))); + assert_eq!(n, 1); + assert!(matches!(acts.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{acts:?}"); + assert_eq!(moved(&acts), Some((100.0, None)), "{acts:?}"); + let (acts, n) = flattenings(|| drag.drag(&c, Point::new(300.0, 326.0))); + assert_eq!(n, 0); + assert_eq!(moved(&acts), Some((200.0, None)), "{acts:?}"); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 70060ebc2b..873ba9fc4b 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -48,9 +48,7 @@ use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_dra use crate::blendwidget::{self, BlendWidgetDrag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{ - MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space, -}; +use crate::objects_on_path::{MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, WidgetDrag, WidgetKind, member_hover, widget_at}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -1235,14 +1233,11 @@ impl Tool for SelectionTool { if blendwidget::over_widget(cx, p) { return Cursor::ArrowHollow; } - if over_rotate_all(cx, p) { - return Cursor::Rotate; - } - if over_space(cx, p) { - return Cursor::ArrowHollow; - } - if over_move_all(cx, p) || over_member(cx, p) { - return Cursor::Move; + match widget_at(cx, p) { + Some(WidgetKind::RotateAll) => return Cursor::Rotate, + Some(WidgetKind::Space) => return Cursor::ArrowHollow, + Some(WidgetKind::MoveAll | WidgetKind::Member) => return Cursor::Move, + None => {} } if over_bracket(cx, p) { return Cursor::PathBracket; @@ -1617,20 +1612,13 @@ impl SelectionTool { self.state = State::BlendWidget(d); return Some(vec![]); } - if let Some(d) = RotateAllDrag::hit(cx, ev) { - self.state = State::RotateAll(d); - return Some(vec![]); - } - if let Some(d) = SpaceDrag::hit(cx, ev) { - self.state = State::Space(d); - return Some(vec![]); - } - if let Some(d) = MoveAllDrag::hit(cx, ev) { - self.state = State::MoveAll(d); - return Some(vec![]); - } - if let Some(d) = MemberDrag::hit(cx, ev) { - self.state = State::Member(d); + if let Some(d) = WidgetDrag::hit(cx, ev) { + self.state = match d { + WidgetDrag::RotateAll(d) => State::RotateAll(d), + WidgetDrag::Space(d) => State::Space(d), + WidgetDrag::MoveAll(d) => State::MoveAll(d), + WidgetDrag::Member(d) => State::Member(d), + }; return Some(vec![]); } if let Some(b) = BracketDrag::hit(cx, ev) { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index aefd9253e9..119c811d0b 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -276,26 +276,27 @@ pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: /// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's /// attachment point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { - use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; + use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, Widgets}; if !matches!(tool, "selection" | "objectsOnPath") { return; } - member_widgets(p, xf, doc, selection); - if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) { + let widgets = Widgets::of(doc, selection, xf.zoom); + member_widgets(p, xf, doc, &widgets.members); + if let Some(w) = widgets.rotate_all { let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); p.line_segment([on, c], Stroke::new(1.0, color)); p.circle_filled(c, ROTATE_ALL_RADIUS_PX, Color32::WHITE); p.circle_stroke(c, ROTATE_ALL_RADIUS_PX, Stroke::new(1.0, color)); } - space_widget(p, xf, doc, selection); - move_all_widget(p, xf, doc, selection); + space_widget(p, xf, doc, widgets.space); + move_all_widget(p, xf, doc, widgets.move_all); } /// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen /// object. They are drawn under the other widgets, which a press takes first. -fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { - use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget}; - for w in MemberWidget::all(doc, selection, xf.zoom) { +fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, widgets: &[vectorcraft_tools::objects_on_path::MemberWidget]) { + use vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX; + for w in widgets { let c = xf.to_screen(w.at); if w.chosen { p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE)); @@ -310,9 +311,9 @@ pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb); /// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square /// joined by a line to the last object's attachment point. It stays while dragged. -fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { - use vectorcraft_tools::objects_on_path::{SPACE_HALF_PX, SpaceWidget}; - let Some(w) = SpaceWidget::of(doc, selection, xf.zoom) else { return }; +fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, w: Option<vectorcraft_tools::objects_on_path::SpaceWidget>) { + use vectorcraft_tools::objects_on_path::SPACE_HALF_PX; + let Some(w) = w else { return }; let (on, c, color) = (xf.to_screen(w.last), xf.to_screen(w.at), c32(doc.layer_color(w.group))); let r = egui::Rect::from_center_size(c, vec2(2.0 * SPACE_HALF_PX, 2.0 * SPACE_HALF_PX)); p.line_segment([on, c], Stroke::new(1.0, color)); @@ -323,9 +324,9 @@ fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, sel /// The Move All widget (beside the Rotate All widget, when the objects have room to move along the /// path): a diamond below the first object joined by a line to its attachment point. It stays while /// dragged. -fn move_all_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { - use vectorcraft_tools::objects_on_path::{MOVE_ALL_HALF_PX, MoveAllWidget}; - let Some(w) = MoveAllWidget::of(doc, selection, xf.zoom) else { return }; +fn move_all_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, w: Option<vectorcraft_tools::objects_on_path::MoveAllWidget>) { + use vectorcraft_tools::objects_on_path::MOVE_ALL_HALF_PX; + let Some(w) = w else { return }; let (on, c, color) = (xf.to_screen(w.first), xf.to_screen(w.at), c32(doc.layer_color(w.group))); let h = MOVE_ALL_HALF_PX; p.line_segment([on, c], Stroke::new(1.0, color)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..f2776290c5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1944,6 +1944,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Objects on Path measures its path once:** placing the objects, finding one object's place and transforming the group flatten each path once and use it both for the path's length and for the points along it, where they flattened it two or three times before; a request for an object past the last one returns nothing without flattening the path, and a move of the group flattens no path. A path whose length adds up past the largest finite number no longer counts as one objects can be attached to. Evidence: `objects_on_path::tests::{stops_and_stop_flatten_the_path_once, transforming_the_group_flattens_each_path_once, placing_the_objects_flattens_the_path_once, a_path_too_long_to_measure_is_not_attachable}` in `crates/doc`. +- **Objects on Path widgets measure the path once:** with one Objects on Path group selected, finding where its Rotate All, Space, Move All and Select/Move widgets go, testing whether the pointer is over one and starting a drag flatten the group's path once, and the canvas draws all four widgets from that one flattening. Before, each widget flattened the path two or three times, and one pointer move over the group could flatten it eleven times. A drag of the Space, Move All or Select/Move widget keeps the measurement it started with and measures again only when the path changes, where it flattened the path twice on every pointer move. The widgets' places, what the pointer hits, the cursors and what each drag changes stay the same. Evidence: `objects_on_path::tests::{each_widget_lookup_flattens_the_path_once, the_widgets_of_a_group_share_one_flattening, a_widget_drag_flattens_the_path_once, a_widget_drag_follows_a_path_that_changed}` in `crates/tools`. + - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. From 347458aff75fc853fa7e4036e5e8390c6a635f91 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:05:40 -0800 Subject: [PATCH 39/89] Place: text files in UTF-16 without a byte-order mark place as UTF-16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Place of a text file with the Unicode character set read UTF-16 only after a byte-order mark. Without one, UTF-16 text was read as UTF-8 (or the 8-bit set): characters outside U+0001 to U+00FF came out as other characters, and each CR LF placed as two paragraph breaks. Place now reads a file without a byte-order mark as UTF-16 by the rule Import Data Sets uses, through the shared byte-order helper: an even length, at least half of the two-byte units holding a NUL byte, the byte order that reads a line break (for one line, the order its NUL bytes give), and valid UTF-16 with no NUL character in that order. Any other file is read as before, as UTF-8 or else the platform's 8-bit set. Docs: docs/mcp.md, file.place params text, docs/target-app-parity.md. --- crates/engine/src/cmd/place.rs | 2 +- crates/engine/src/cmd/place/text.rs | 19 ++++++++++-- crates/engine/src/tests/textimport.rs | 43 +++++++++++++++++++++++++++ docs/mcp.md | 11 +++++-- docs/target-app-parity.md | 1 + 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 20f19ef803..24dba09f37 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index 25f5f36dbd..7d8afc92d4 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -3,6 +3,7 @@ use serde_json::Value; +use super::super::utf16_order_without_mark; use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param}; /// Text files as `file.place` reports them (not a format `document.open` reads). @@ -76,8 +77,10 @@ pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result<String> Ok(clean(&decode(bytes, o), o)) } -/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are -/// read as the platform's 8-bit set), or the platform's 8-bit character set. +/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark, or without one when the +/// file has an even length, reads as valid UTF-16 with no NUL character in the byte order +/// [`utf16_order_without_mark`] gives; bytes that aren't UTF-8 are read as the platform's 8-bit +/// set), or the platform's 8-bit character set. fn decode(bytes: &[u8], o: TextOptions) -> String { let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() }; if o.ansi { @@ -91,7 +94,17 @@ fn decode(bytes: &[u8], o: TextOptions) -> String { [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), [0xFF, 0xFE, rest @ ..] => utf16(rest, true), [0xFE, 0xFF, rest @ ..] => utf16(rest, false), - _ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string), + _ => { + let without_mark = (bytes.len().is_multiple_of(2) && bytes.contains(&0)) + .then(|| utf16_order_without_mark(bytes)) + .flatten() + .and_then(|le| { + let units = bytes.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok() + }) + .filter(|text| !text.contains('\0')); + without_mark.unwrap_or_else(|| std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string)) + } } } diff --git a/crates/engine/src/tests/textimport.rs b/crates/engine/src/tests/textimport.rs index 0749964ee6..b369346096 100644 --- a/crates/engine/src/tests/textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -68,6 +68,49 @@ fn type_placed_on_an_artboard_edge_takes_that_artboards_frame() { } } +/// `text` as UTF-16 without a byte-order mark, little-endian when `le` is set. +fn utf16(text: &str, le: bool) -> Vec<u8> { + text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect() +} + +/// Unicode text in UTF-16 without a byte-order mark is read as UTF-16 when at least half of its +/// two-byte units hold a NUL byte, in the byte order that reads a line break, or for one line, the +/// order its NUL bytes give. Other bytes keep today's reading. +#[test] +fn utf16_without_a_byte_order_mark_places_by_its_nul_bytes_and_line_breaks() { + let mut s = session(); + for le in [true, false] { + let r = place_text(&mut s, &utf16("Hello, 世界 café!\r\nSecond line", le), json!({})); + assert_eq!(placed(&s, &r).0, "Hello, 世界 café!\nSecond line", "little-endian: {le}"); + let r = place_text(&mut s, &utf16("One line, 世界", le), json!({})); + assert_eq!(placed(&s, &r).0, "One line, 世界", "little-endian: {le}"); + } + // One line with NUL bytes in both places (U+4E00 ends in a zero byte) takes the order more of + // its units give, and a tie is little-endian. + let r = place_text(&mut s, &utf16("一AB", false), json!({})); + assert_eq!(placed(&s, &r).0, "一AB"); + let r = place_text(&mut s, &utf16("一A", true), json!({})); + assert_eq!(placed(&s, &r).0, "一A"); + // Not read as UTF-16, so read as UTF-8 or else Windows-1252 with NUL bytes dropped: an odd + // length, an unpaired surrogate, a NUL character, and line breaks in both byte orders (U+0A00). + let r = place_text(&mut s, b"A\0B", json!({})); + assert_eq!(placed(&s, &r).0, "AB"); + let r = place_text(&mut s, b"\0a\0b\xd8\0", json!({})); + assert_eq!(placed(&s, &r).0, "abØ"); + let r = place_text(&mut s, b"\0\0\xc3\xa9", json!({})); + assert_eq!(placed(&s, &r).0, "é"); + let r = place_text(&mut s, &utf16("A\u{0A00}\nB", true), json!({})); + assert_eq!(placed(&s, &r).0, "A\n\nB"); + // Fewer than half of the two-byte units hold a NUL byte: UTF-8, and Windows-1252. + let r = place_text(&mut s, "Fünf\0 Zeilen!".as_bytes(), json!({})); + assert_eq!(placed(&s, &r).0, "Fünf Zeilen!"); + let r = place_text(&mut s, b"caf\xe9\0x", json!({})); + assert_eq!(placed(&s, &r).0, "caféx"); + // ANSI reads each byte as a character of the 8-bit set; NUL and other control bytes are dropped. + let r = place_text(&mut s, &utf16("Hi, 世界", true), json!({"text": {"characterSet": "ansi"}})); + assert_eq!(placed(&s, &r).0, "Hi, NLu"); +} + #[test] fn text_files_report_and_queue_with_their_options() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index a68645348e..7bc318fb44 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7058,9 +7058,14 @@ the document; so does `object.expand`. Placing a text file (`.txt`) sets it as area type: `file.place {path | name+dataBase64, text?: {characterSet?: "unicode" | "ansi", platform?: "windows" | "mac", removeLineReturns?, removeParagraphReturns?, replaceSpaces?: n}}` (Text Import Options). Unicode reads UTF-8, or UTF-16 with a byte-order mark; ANSI reads Windows-1252 (Mac Roman on -`mac`). `removeLineReturns` joins the lines of each block into one paragraph, `removeParagraphReturns` drops blank -lines, `replaceSpaces: 3` turns runs of 3 or more spaces into tabs. The frame fills `rect`, the replaced object's -bounds, or the artboard less a 36 pt margin. +`mac`). Unicode reads a file without a byte-order mark as UTF-16 by the rule `dataset.import` uses: at least half of +its two-byte units hold a NUL byte, in the byte order that reads a line break, or for a file with one line, big-endian +when more of its units start with a NUL byte than end with one, and little-endian otherwise. Any other file is read as +UTF-8, else as the platform's 8-bit set: one with fewer NUL bytes, one with an odd length, one where both byte orders +read a line break, and one that isn't valid UTF-16 in that order or holds a NUL character. `removeLineReturns` joins +the lines of each block into one paragraph, `removeParagraphReturns` drops blank lines, `replaceSpaces: 3` turns runs +of 3 or more spaces into tabs. The frame fills `rect`, the replaced object's bounds, or the artboard less a 36 pt +margin. ```json {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/notes.txt","text":{"removeLineReturns":true,"removeParagraphReturns":true}}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8fe37830a2..f124ce3249 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1749,6 +1749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests/textimport.rs`). - File › Place reads Word 97-2003 `.doc` documents as area type with the same formatting, read from the main text's pieces, the style sheet's paragraph and character styles and direct formatting. Table rows are paragraphs with their cells separated by tabs and fields place as their results; fields without a result, hidden and deleted text, pictures, headers, footers, notes, comments and text boxes are left out, and `warnings` names each kind left out or changed. Encrypted documents, documents last saved with fast save and Word 6.0 and Word 95 documents are refused (`file.place`, `crates/engine/src/tests/textimport.rs`). - Placing a Word 97-2003 `.doc` document works out each style's formatting once and each property run's once, and refuses property pages that are out of order, listed twice or overlap, a style sheet that holds fewer styles than it counts, styles based on each other in a loop, and more than 100,000 runs of character or of paragraph properties, 16 MiB of properties or 100,000 paragraphs, hidden ones included (`file.place`, `crates/engine/src/tests/textimport.rs`). + - File › Place of a text file with the Unicode character set reads UTF-16 without a byte-order mark by the rule Import Data Sets… uses (at least half of its two-byte units hold a NUL byte; the byte order that reads a line break; valid UTF-16 with no NUL character), instead of reading it as UTF-8 with other characters in place of those outside U+0001 to U+00FF (`file.place`, `crates/engine/src/tests/textimport.rs`). - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - A file read for `file.place`, or files read for the place cursor, apart from the session (`Session::place_read`, `Session::place_queue_read`) go only into the document they were read for: while another document is active, or once that document is closed, they are refused with "the document it was prepared for is no longer active" and no document, selection, tool or undo step changes (`crates/engine/src/tests/place.rs`). - On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. From 04b90d88d070cdf82611e712ce56beee708933cc Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 02:29:18 -0800 Subject: [PATCH 40/89] Tests: raster effects draw the same twice while another test assigns a document profile tests::newdoc::a_profile_brings_the_color_profile_its_file_carries and tests::saveoptions::an_embedded_profile_is_restored give their front document a non-sRGB RGB profile. That profile becomes the process-wide active color settings and stays active until a document without one comes to the front. Every color the engine test process renders goes through those settings, so the test tests::rasterfilters::each_effect_changes_the_canvas_and_draws_the_same_twice could draw the same art in two colors and fail with "<effect> is deterministic". The two tests now run again in a child process of the test binary through a shared helper, tests::in_own_process. The SVG Drop Shadow test for a non-sRGB working space uses the helper in place of its own copy, and the GLOBAL doc comment in tests/colormgmt.rs names this route. The rasterfilters test now checks that the color settings are the defaults before it draws and that nothing replaced them while it drew. A test that changes them in the same process makes it fail with "another test changed the color settings while this one drew". With the three tests run together (--test-threads=3) on Linux, the rasterfilters test failed 10 of 10 runs without the child processes, each time with that message, and passed 10 of 10 with them. --- crates/engine/src/cmd/fileio/tests_svg.rs | 16 ++-------------- crates/engine/src/tests.rs | 19 +++++++++++++++++++ crates/engine/src/tests/colormgmt.rs | 5 ++++- crates/engine/src/tests/newdoc.rs | 3 +++ crates/engine/src/tests/rasterfilters.rs | 11 +++++++++-- crates/engine/src/tests/saveoptions.rs | 3 +++ 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index eb3989597f..121cc0b279 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -352,20 +352,8 @@ fn svg_darkness_preserves_source_model_and_gradient_appearance() { #[test] fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() { - const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE"; - if std::env::var_os(CHILD).is_none() { - // Color settings are process-wide: exercise this profile in a separate test process. - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"]) - .env(CHILD, "1") - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), - "isolated color profile test failed:\n{stdout}\n{}", - String::from_utf8_lossy(&output.stderr), - ); + // Color settings are process-wide: exercise this profile in a separate test process. + if !crate::tests::in_own_process("cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space") { return; } use vectorcraft_color::{Color, Paint, cms}; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2f849901a4..57445a36be 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -241,6 +241,25 @@ pub(crate) fn key_guide(s: &Session) -> Option<usize> { st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied()) } +/// Runs the unit test `path` (`tests::newdoc::...`) again in a child process of the test binary +/// → whether this is that child. For a test that changes process-wide state the tests running +/// beside it read, such as the active color settings. +pub(crate) fn in_own_process(path: &str) -> bool { + const CHILD: &str = "VECTORCRAFT_TEST_OWN_PROCESS"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let output = + std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", path, "--nocapture"]).env(CHILD, "1").output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "{path} failed in its own process:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + false +} + #[test] fn create_and_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index 493f569805..6ab0569e82 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -16,7 +16,10 @@ use crate::*; /// Every test here reads or changes the process-wide proof view or colour settings: one at a time. /// Tests that switch the Color Settings away from the defaults live in `tests/color_settings.rs` /// (their own process): every colour shown or rendered goes through them, so no lock could cover -/// all the tests that would see the change. +/// all the tests that would see the change. A unit test that assigns its front document a profile +/// other than the working space changes the active settings the same way until another document +/// comes to the front: it starts with `crate::tests::in_own_process`, which runs it again in a +/// child process. pub(crate) static GLOBAL: Mutex<()> = Mutex::new(()); fn session() -> Session { diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 209d22a42a..094f634799 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -683,6 +683,9 @@ fn the_new_document_links_from_the_profile_file() { /// is a warning. #[test] fn a_profile_brings_the_color_profile_its_file_carries() { + if !super::in_own_process("tests::newdoc::a_profile_brings_the_color_profile_its_file_carries") { + return; + } use vectorcraft_color::cms; let (mut s, dir) = with_profiles("icc"); let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); diff --git a/crates/engine/src/tests/rasterfilters.rs b/crates/engine/src/tests/rasterfilters.rs index 236580d5b7..534f79b1bc 100644 --- a/crates/engine/src/tests/rasterfilters.rs +++ b/crates/engine/src/tests/rasterfilters.rs @@ -5,6 +5,7 @@ //! as images of the effected object. use serde_json::{Value, json}; +use vectorcraft_color::cms; use vectorcraft_doc::NodeKind; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; @@ -173,16 +174,22 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() { EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO) { let (mut s, g) = striped(); + // Every color drawn goes through the process-wide color settings. A test that changes them + // runs in a process of its own (`crate::tests::in_own_process`). + let colors = cms::active(); + assert_eq!(cms::active_settings(), cms::ColorSettings::default(), "{id}: another test left the color settings changed"); let plain = render(&s); let params: Value = serde_json::from_str(params).unwrap(); s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap(); let a = render(&s); - assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); - assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic"); + let again = render(&s); // The multithreaded pipeline draws the same. let mut mt = Renderer::new(); mt.threads = 4; let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + assert!(std::sync::Arc::ptr_eq(&colors, &cms::active()), "{id}: another test changed the color settings while this one drew"); + assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); + assert_eq!(a.pixels, again.pixels, "{id} is deterministic"); assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b)); } } diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index a5b19563a9..0f7205eaae 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -112,6 +112,9 @@ fn a_linked_pngs_bytes_are_embedded() { #[test] fn an_embedded_profile_is_restored() { + if !super::in_own_process("tests::saveoptions::an_embedded_profile_is_restored") { + return; + } let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); let icc = cms::icc_bytes(&builtin.name).unwrap(); let name = format!("Studio RGB {}", std::process::id()); From 9e0882199b0d30c8d0a94128799f8b401309b16e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:10:01 -0800 Subject: [PATCH 41/89] Image Trace: scaling a large image down keeps the color of pixels next to clear ones An image over 64 megapixels is scaled down before tracing with a box filter that averaged red, green and blue without weighing them by alpha. A clear pixel (stored as transparent black) next to an opaque one pulled the scaled pixel's color toward black: an opaque red and a clear pixel gave a half-clear red at half its brightness. Each scaled pixel now takes its alpha as the mean of its block and its color from the block's colors weighted by their alpha. A wholly clear block keeps the plain mean of its colors. Every source pixel now counts in exactly one block, so the block edges differ slightly from before and an opaque image can also scale to slightly different values. --- crates/trace/src/raster.rs | 58 +++++++++++++++++++++++++++++++++++--- crates/trace/src/tests.rs | 24 ++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 5e198e6fb7..51312c685a 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -48,6 +48,54 @@ pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { (nw, nh) } +/// The `w × h` RGBA8 `rgba` reduced to `nw × nh` (at most `w × h`): each pixel covers a block of +/// whole source pixels, and every source pixel falls in exactly one block. A pixel's alpha is the +/// mean of its block's alphas, and its color is the block's colors weighted by their alpha, so a +/// clear pixel next to an opaque one does not darken or tint it. A wholly clear block (every alpha +/// 0) keeps the plain mean of its colors. +pub(crate) fn box_reduced(rgba: &[u8], w: u32, h: u32, nw: u32, nh: u32) -> Vec<u8> { + let span = |i: u32, n: u32, of: u32| { + let at = |i: u32| usize::try_from(u64::from(i) * u64::from(of) / u64::from(n.max(1))).unwrap_or(usize::MAX); + let start = at(i); + start..at(i + 1).max(start + 1) + }; + let cols: Vec<_> = (0..nw).map(|x| span(x, nw, w)).collect(); + let mut out = Vec::with_capacity(nw as usize * nh as usize * 4); + // Per output column: the alpha-weighted color sums, the alpha sum and the plain color sums. + let mut sums = vec![[0u64; 7]; cols.len()]; + let row_len = w as usize * 4; + for ty in 0..nh { + let rows = span(ty, nh, h); + sums.fill([0; 7]); + for y in rows.clone() { + let Some(row) = rgba.get(y * row_len..(y + 1) * row_len) else { break }; + for (s, c) in sums.iter_mut().zip(&cols) { + let px = row.get(c.start * 4..c.end * 4).unwrap_or_default(); + for &[r, g, b, a] in px.as_chunks::<4>().0 { + let a64 = u64::from(a); + s[0] += u64::from(r) * a64; + s[1] += u64::from(g) * a64; + s[2] += u64::from(b) * a64; + s[3] += a64; + s[4] += u64::from(r); + s[5] += u64::from(g); + s[6] += u64::from(b); + } + } + } + for (s, c) in sums.iter().zip(&cols) { + let count = (rows.len() * c.len()).max(1) as u64; + let mean = |v: u64, n: u64| u8::try_from((v + n / 2) / n).unwrap_or(u8::MAX); + if s[3] == 0 { + out.extend_from_slice(&[mean(s[4], count), mean(s[5], count), mean(s[6], count), 0]); + } else { + out.extend_from_slice(&[mean(s[0], s[3]), mean(s[1], s[3]), mean(s[2], s[3]), mean(s[3], count)]); + } + } + } + out +} + /// An RGBA8 raster (row-major, top row first). #[derive(Clone, Debug, PartialEq)] pub struct Raster { @@ -150,12 +198,14 @@ impl Raster { let mut limits = image::Limits::default(); limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); reader.limits(limits); - let img = reader.decode().map_err(decode_err)?.into_rgba8(); + Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS)) + } + /// `img` scaled down, aspect kept, to the largest size of at most `max` pixels. + pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { let (w, h) = img.dimensions(); - let (nw, nh) = fit_size(w, h, MAX_PIXELS); + let (nw, nh) = fit_size(w, h, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - let small = image::imageops::thumbnail(&img, nw, nh); - Ok(Self { width: nw, height: nh, rgba: small.into_raw() }) + Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec<u8> { diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 3b4e84989a..aa452347ef 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1085,6 +1085,30 @@ fn decode_to_fit_scales_an_oversized_image_down() { assert!(matches!(Raster::decode_to_fit(&png_header(9000, 9000)), Err(TraceError::Decode(_)))); } +/// Scaling an oversized image down weighs each color by its alpha: a clear pixel next to an +/// opaque one leaves the opaque color as it is and only lowers the alpha. +#[test] +fn scaling_down_weighs_color_by_alpha() { + use crate::raster::box_reduced; + const RED: [u8; 4] = [200, 30, 30, 255]; + // Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit` + // does: each scaled pixel covers a red and a clear one. + let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] }); + let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8(); + let fitted = Raster::fitted(&decoded, 2); + assert_eq!((fitted.width, fitted.height), (2, 1)); + assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat()); + // A partly clear blue pulls an opaque red toward it by its alpha. + let mixed = Raster::from_fn(2, 1, |x, _| if x == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 85] }); + assert_eq!(box_reduced(&mixed.rgba, 2, 1, 1, 1), [191, 0, 64, 170]); + // A wholly clear block stays clear, with the plain mean of its colors. + let clear = Raster::from_fn(2, 1, |x, _| [100 * x as u8, 0, 0, 0]); + assert_eq!(box_reduced(&clear.rgba, 2, 1, 1, 1), [50, 0, 0, 0]); + // A 3 → 2 reduction counts every source pixel once. + let ramp = Raster::from_fn(3, 3, |x, y| [(x * 60 + y * 10) as u8, 0, 0, 255]); + assert_eq!(box_reduced(&ramp.rgba, 3, 3, 2, 2), [0, 0, 0, 255, 90, 0, 0, 255, 15, 0, 0, 255, 105, 0, 0, 255]); +} + /// Four flat colours with anti-aliased (blended) edges between the quadrants. fn flat_quads() -> Raster { const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]]; From ebf498a823c2d5628843cbc29c9fe11610d096f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:20:55 -0800 Subject: [PATCH 42/89] EPS import: kshow runs its procedure between the characters of a composite font In a composite font the file carries (FMapType 2, or a CMap), kshow ran its procedure zero times. It now runs between each two characters with the values of their codes (font number times 256 plus code, or the CMap code), and the next character follows where the procedure moves the current point (PLRM 3rd ed., kshow). What the procedure draws goes after the type, outside the glyphs' group, as for live type. --- crates/eps/src/import/cid.rs | 10 +++--- crates/eps/src/import/tests_cid.rs | 10 ++++++ crates/eps/src/import/tests_composite.rs | 29 ++++++++++++++++ crates/eps/src/import/text.rs | 42 ++++++++++++++++++++---- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index 4c00486de9..18498352f7 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -70,6 +70,8 @@ pub(super) struct CidChar { /// The base-font code or glyph name a `bfchar` or `bfrange` mapping gives it, instead of a /// CID. pub base: Option<Base>, + /// The value of its code (`<8001>` is 32769). + pub value: u32, } impl CMap { @@ -146,12 +148,12 @@ impl CMap { work += at.map_or(self.bases.len(), |k| k + 1) as u64; let base = at.and_then(|k| self.bases.get(k)).and_then(|m| Some((m.4, base_of(&m.3, c - m.1)?))); out.push(match (matched.is_some(), find(&self.cids, &mut work), base) { - (true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None }, - (true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base) }, + (true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None, value: c }, + (true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base), value: c }, // No character mapping: the notdef mapping's CID, else font 0's CID 0. _ => match find(&self.notdefs, &mut work) { - Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None }, - _ => CidChar { font: 0, cid: 0, notdef: None, base: None }, + Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None, value: c }, + _ => CidChar { font: 0, cid: 0, notdef: None, base: None, value: c }, }, }); i += n; diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 8350cd514b..876d3df412 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -136,6 +136,16 @@ fn codes_map_by_their_codespace_with_notdef_fallbacks() { width("<01>", 30.0); } +/// `kshow` over a CMap runs its procedure between each two codes with their values, whatever +/// their length, and the next character follows where it moves the point (PLRM 3rd ed., `kshow`). +#[test] +fn kshow_runs_between_each_two_cmap_codes() { + let fonts = String::from_utf8(cid_font(true)).unwrap() + CMAP; + let check = |cond: &str| check(&format!("{fonts} /TestFont 50 selectfont {cond}")); + check("gsave nulldevice 10 20 moveto [ {} <418001> kshow ] grestore aload length 2 eq exch 16#8001 eq and exch 65 eq and"); + check("gsave nulldevice 10 20 moveto {pop pop 100 0 rmoveto} <418001> kshow currentpoint grestore pop 165 sub abs 0.01 lt"); +} + /// `usecmap` brings in another CMap's ranges and mappings; the CMap's own come after them. #[test] fn usecmap_takes_the_parents_mappings() { diff --git a/crates/eps/src/import/tests_composite.rs b/crates/eps/src/import/tests_composite.rs index e682ce10ec..2f4074af97 100644 --- a/crates/eps/src/import/tests_composite.rs +++ b/crates/eps/src/import/tests_composite.rs @@ -119,6 +119,35 @@ fn widthshow_matches_a_composite_character_by_font_number_and_code() { check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh)); } +/// `kshow` runs its procedure between each two characters of a composite font, with the values of +/// their codes (font number times 256 plus code), and the next character follows where it moves +/// the current point (PLRM 3rd ed., `kshow`). +#[test] +fn kshow_runs_between_each_two_composite_characters() { + let (_, wh) = glyph('H'); + let (_, wi) = glyph('I'); + let fonts = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def"); + let check = |cond: String| super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont {cond}")); + check("gsave nulldevice 10 20 moveto [ {} (\\000\\001\\001\\002\\000\\001) kshow ] grestore aload length 4 eq exch 1 eq and exch 258 eq and exch 258 eq and exch 1 eq and".into()); + check(format!( + "gsave nulldevice 10 20 moveto {{pop pop 100 0 rmoveto}} (\\000\\001\\000\\002) kshow currentpoint grestore pop {} sub abs 0.01 lt", + 10.0 + 50.0 * (wh + wi) + 100.0 + )); + // Drawn, the I starts where the procedure moved the point. + let r = read(&format!("{fonts}/Comp findfont 50 scalefont setfont 10 20 moveto {{pop pop 100 0 rmoveto}} (\\000\\001\\000\\002) kshow")); + let paths: Vec<_> = all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| bounds(n)).collect(); + assert_eq!(paths.len(), 2, "{paths:?}"); + assert!(paths[1].x0 > 10.0 + 50.0 * wh + 100.0 - 1.0, "{paths:?}"); + // A procedure that draws: its square goes after the glyphs' group, outside it. + let r = read(&format!( + "{fonts}/Comp findfont 50 scalefont setfont 10 20 moveto {{pop pop currentpoint 2 2 rectfill}} (\\000\\001\\000\\002\\000\\001) kshow" + )); + let top = super::tests::objects(&r.document); + assert_eq!(top.len(), 3, "{top:?}"); + assert_eq!(top[0].children().map(Vec::len), Some(3)); + assert!(top[1..].iter().all(|n| matches!(n.kind, NodeKind::Path { .. }) && (bounds(n).width() - 2.0).abs() < 0.01), "{top:?}"); +} + /// Measuring a composite font's Type 42 glyphs (`stringwidth` on a long string in a `loop`) /// counts against the operation budget, as drawing them does. #[test] diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 515e0b12ec..15b9ca34c6 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -999,6 +999,8 @@ struct Mapped { cid: Option<(u32, Option<u32>)>, outer: Affine, vertical: bool, + /// The value of the character's code in the shown string (what `kshow` gives its procedure). + value: u32, } /// Type in a CIDFont only named, over a predefined CMap ([`Interp::named_cid_type`]): its codes @@ -1097,7 +1099,8 @@ impl Interp<'_> { if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize)) && font_type(&d.borrow()) != Some(0.0) { - out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false }); + let value = u32::from(pick) * 256 + u32::from(code); + out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false, value }); } } Ok(Some(out)) @@ -1124,21 +1127,28 @@ impl Interp<'_> { .filter_map(|c| match (fonts.get(c.font), c.base) { // A base font's code, or its glyph name (at the code its encoding gives it, if any). (Some(Obj::Dict(d)), Some(Base::Code(code))) => { - Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical, value: c.value }) } (Some(Obj::Dict(d)), Some(Base::Name(name))) => { let code = encoded(&d.borrow(), &name); let name = code.is_none().then_some(name); - Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical, value: c.value }) } // A code without a mapping in a base font (not a CIDFont): its code 0, the // `.notdef` glyph CID 0 stands for in a CIDFont. (Some(Obj::Dict(d)), None) if !d.borrow().contains_key(&Key::name("CIDFontType")) => { - Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical }) - } - (Some(Obj::Dict(d)), None) => { - Some(Mapped { font: d.clone(), pick: 0, code: (c.cid & 0xff) as u8, name: None, cid: Some((c.cid, c.notdef)), outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical, value: c.value }) } + (Some(Obj::Dict(d)), None) => Some(Mapped { + font: d.clone(), + pick: 0, + code: (c.cid & 0xff) as u8, + name: None, + cid: Some((c.cid, c.notdef)), + outer, + vertical, + value: c.value, + }), _ => None, }) .collect()) @@ -1346,6 +1356,12 @@ impl Interp<'_> { fn show_composite(&mut self, glyphs: &[Mapped], spacing: &Spacing) -> Res { let start = self.out.drawn.len(); let mut text = String::new(); + let kern: Vec<u32> = match spacing { + Spacing::Kern(_) => glyphs.iter().map(|g| g.value).collect(), + _ => Vec::new(), + }; + // Where in the objects drawn each run of `kshow`'s procedure put its art. + let mut kerned = Vec::new(); for (i, g) in glyphs.iter().enumerate() { let at = self.user_point()?; let w = self.composite_glyph(g, at, Draw::Fill)?; @@ -1353,6 +1369,12 @@ impl Interp<'_> { // times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8). let d = spacing.advance(i, i64::from(g.pick) * 256 + i64::from(g.code), w); self.g.cur = Some(self.xf() * (at + d)); + // `kshow`'s procedure runs between each two characters with the values of their codes. + let before = self.out.drawn.len(); + self.kern(spacing, &kern, i, at + d)?; + if self.out.drawn.len() > before { + kerned.push(before..self.out.drawn.len()); + } // A CID stands for no character (its code is only the CID's low byte). let c = if g.cid.is_some() { '\u{2022}' } else { g.name.as_deref().map_or_else(|| code_char(&g.font.borrow(), g.code), glyph_char) }; if c != '\u{2022}' { @@ -1364,7 +1386,13 @@ impl Interp<'_> { { text = name.to_string(); } + // What `kshow`'s procedure draws goes after the type, outside its group. + let mut art = Vec::new(); + for run in kerned.into_iter().rev() { + art.splice(0..0, self.out.drawn.drain(run)); + } self.group_glyphs(start, &text); + self.out.drawn.extend(art); Ok(()) } From 5d3cd69b6a56e5e40456b849f6273ec11d746b6e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 02:54:38 -0800 Subject: [PATCH 43/89] Undo: no step when Ruby, Character or another type command changes nothing type.ruby, text.setStyle, text.setText, text.fitHeadline, text.reshapeArea, text.reshapePath and text.setPathHandle recorded an undo step even when the call left the type as it was: the same ruby reading again, a removal where there is no ruby, attributes the characters already have, the same text, a second run of Fit Headline, or a reshape by 0. Each now runs on a copy and records a step only when one of its type objects (or, for text.setStyle, a graph's kept label styles) changed; otherwise the undo and redo steps and the saved state stay as they were. Area Type Options already skips unchanged options, and Scale Text Attributes returns an error for type with no scale to bake, so neither changes. Type Orientation, Optical Margin Alignment, System Layout and Fractional Widths are fixed in #7505. --- crates/engine/src/cmd/typecmd.rs | 73 ++++++---- crates/engine/src/cmd/typecmd/noop_tests.rs | 143 ++++++++++++++++++++ docs/mcp.md | 8 +- 3 files changed, 198 insertions(+), 26 deletions(-) create mode 100644 crates/engine/src/cmd/typecmd/noop_tests.rs diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b3fab4a2bc..26d6a02a94 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> { "Set Text", [], None, - "{id?|ids?, text} replace the contents of text objects (keeps the first run's style) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", + "{id?|ids?, text} replace the contents of text objects (keeps the first run's style); no undo step when no object changes (each already has the text in one style, every paragraph with the first one's attributes, and no ruby) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", has_doc, set_text ), @@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reshape Type Area", [], None, - "{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step", + "{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step; no undo step when no frame changes (dx and dy both 0)", has_doc, reshape_area ), @@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reshape Type Path", [], None, - "{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path)", + "{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path); no undo step when the path does not change (dx and dy both 0)", has_doc, reshape_path ), @@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> { "Move Type Path Handle", [], None, - "{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)", + "{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path); no undo step when the handle is already there and the path does not change", has_doc, set_path_handle ), @@ -131,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> { "Fit Headline", ["Type"], None, - "{ids?|id?} track the first line of area type so it fills the frame width → {tracking}", + "{ids?|id?} track the first line of area type so it fills the frame width; no undo step when the first line already has that tracking → {tracking}", has_selection, fit_headline ), @@ -140,7 +140,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), @@ -149,7 +149,7 @@ pub fn specs() -> Vec<CommandSpec> { "Ruby…", [], None, - "{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}", + "{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead; no undo step when the ruby does not change (the same reading and options again, or `remove` where there is none) → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}", has_selection, ruby ), @@ -244,8 +244,8 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result<Value> { } /// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one -/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby -/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text). +/// undo step (none when nothing changes). Without `start`/`end` the range is all the text; a new +/// reading replaces the ruby over the range. The reply lists the first object's ruby (start/end in bytes of its plain text). fn ruby(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "type.ruby"; let ids = text_targets(s, p, C)?; @@ -272,7 +272,8 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> { } let overhang = switch_or(p, "overhang", true, C)?; let mut first: Option<Value> = None; - s.edit("Ruby", |d, _| { + // The same reading again, or a removal where there is no ruby, is no undo step. + edit_if_nodes_changed(s, "Ruby", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); @@ -299,7 +300,7 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> { first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null)); } } - Ok(()) + Ok(((), false)) })?; Ok(json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(), "ruby": first.unwrap_or(Value::Null)})) } @@ -734,6 +735,19 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })) } +/// [`Session::edit`] for a type command that may set what the type already has: `f` runs on a +/// copy of the document, and it becomes an undo step only when one of the objects `ids` differs +/// afterwards or `f` returns true for a change of its own elsewhere (a graph's kept label styles). +/// When nothing changed, the document, its undo and redo steps and its saved state stay as they +/// were. Only the objects are compared, not the whole document, whose embedded images can be large. +fn edit_if_nodes_changed<T>(s: &mut Session, label: &str, ids: &[NodeId], f: impl FnOnce(&mut Document) -> Result<(T, bool)>) -> Result<T> { + super::colorcmds::edit_if_changed(s, label, |before, d| { + let (v, other) = f(d)?; + let changed = other || ids.iter().any(|id| before.node(*id) != d.node(*id)); + Ok((v, changed)) + }) +} + pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> { let ids = targets(s, p)?; let t = text_ids(&s.doc()?.doc, &ids); @@ -828,7 +842,8 @@ pub(crate) fn style_chars(t: &mut TextObject, range: Option<TextRange>, mut f: i fn set_text(s: &mut Session, p: &Value) -> Result<Value> { let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string(); let ids = text_targets(s, p, "text.setText")?; - s.edit("Typing", |d, _| { + // The text the type already has is no undo step. + edit_if_nodes_changed(s, "Typing", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let style = t.first_style(); @@ -839,7 +854,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> { t.ruby.clear(); refresh_bounds(t); } - Ok(()) + Ok(((), false)) })?; Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))) } @@ -912,7 +927,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> { let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some()); // The fonts the styled characters are set in, for the warning. let mut set = std::collections::BTreeSet::new(); - s.edit("Character", |d, _| { + // Attributes the characters already have are no undo step. + edit_if_nodes_changed(s, "Character", &ids, |d| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?; @@ -987,8 +1003,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> { } // Graph labels keep a change made to all of their text through the graph being drawn again (sizes as given, // not in a scaled label's own units). + let mut labels_changed = false; if range.is_none() { - super::graph::capture_label_styles(d, &ids, &|st| { + labels_changed = super::graph::capture_label_styles(d, &ids, &|st| { if let Some(f) = &font { st.font_family = f.clone(); st.font_version = None; @@ -1031,7 +1048,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> { } }); } - Ok(()) + Ok(((), labels_changed)) })?; let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })); if let Some(w) = super::fonts::set_font_warning(set) { @@ -1329,14 +1346,16 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> { items.push((id, refs)); } let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); - s.edit("Reshape Type Area", |doc, _| { + // Anchors moved by no distance are no undo step. + let ids: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect(); + edit_if_nodes_changed(s, "Reshape Type Area", &ids, |doc| { for (id, refs) in &items { let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) }; if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) { return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas")); } } - Ok(()) + Ok(((), false)) })?; ok() } @@ -1356,7 +1375,8 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> { if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not type on a path outside perspective")); } - s.edit("Reshape Type Path", |doc, _| { + // Anchors moved by no distance are no undo step. + edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| { let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let before = t.kind.clone(); let within = |t: &TextObject| { @@ -1367,7 +1387,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad(C, "no such path anchor, or the path would reach past the canvas")); } refresh_bounds(t); - Ok(()) + Ok(((), false)) })?; ok() } @@ -1385,7 +1405,8 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> { if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not type on a path outside perspective")); } - s.edit("Reshape Type Path", |doc, _| { + // A handle moved to where it is is no undo step. + edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| { let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let before = t.kind.clone(); let within = |t: &TextObject| { @@ -1396,7 +1417,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad(C, "no such path anchor, or the path would reach past the canvas")); } refresh_bounds(t); - Ok(()) + Ok(((), false)) })?; ok() } @@ -1407,6 +1428,8 @@ mod block; pub(crate) use block::needed_room; #[cfg(test)] mod block_tests; +#[cfg(test)] +mod noop_tests; /// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. /// The third value is where the first line ends in the plain text. @@ -1448,7 +1471,9 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> { plans.push((*id, (lo * 10.0).floor() / 10.0)); } let first = plans.first().map(|p| p.1).ok_or_else(|| bad(C, "select area type whose first line can fit its frame"))?; - s.edit("Fit Headline", |d, _| { + // A headline that already has its fitted tracking is no undo step. + let fitted: Vec<NodeId> = plans.iter().map(|(id, _)| *id).collect(); + edit_if_nodes_changed(s, "Fit Headline", &fitted, |d| { for (id, tr) in &plans { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { let end = t.plain_text().find('\n').unwrap_or(usize::MAX).min(vectorcraft_text::edit::runs_len(&t.runs)); @@ -1456,7 +1481,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> { refresh_bounds(t); } } - Ok(()) + Ok(((), false)) })?; Ok(json!({ "tracking": first })) } diff --git a/crates/engine/src/cmd/typecmd/noop_tests.rs b/crates/engine/src/cmd/typecmd/noop_tests.rs new file mode 100644 index 0000000000..02481d8bee --- /dev/null +++ b/crates/engine/src/cmd/typecmd/noop_tests.rs @@ -0,0 +1,143 @@ +use super::*; + +/// The document and the number of undo steps, to compare before and after a command that sets +/// what the type already has. +fn state(s: &Session) -> (std::sync::Arc<Document>, usize) { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len()) +} + +/// Assert that `cmd` with `p` leaves the document and its undo history as they were. +fn records_nothing(s: &mut Session, cmd: &str, p: Value) { + let before = state(s); + s.execute(cmd, &p).unwrap(); + let after = state(s); + assert!(*after.0 == *before.0, "{cmd} {p} changed the document"); + assert_eq!(after.1, before.1, "{cmd} {p} recorded an undo step"); +} + +/// Assert that `cmd` with `p` changes the document in exactly one undo step. +fn records_one_step(s: &mut Session, cmd: &str, p: Value) { + let before = state(s); + s.execute(cmd, &p).unwrap(); + let after = state(s); + assert!(*after.0 != *before.0, "{cmd} {p} left the document as it was"); + assert_eq!(after.1, before.1 + 1, "{cmd} {p} did not record one undo step"); +} + +fn session_with(text: &str, area: Option<Value>) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let mut p = json!({"x": 10, "y": 20, "text": text, "size": 24}); + if let Some(a) = area { + p["area"] = a; + } + let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + (s, id) +} + +/// Ruby set again with the same reading and options, or removed where there is none, records no +/// undo step; a step that does change the ruby still undoes in one. +#[test] +fn ruby_that_changes_nothing_records_no_undo_step() { + let (mut s, id) = session_with("漢字です", None); + records_nothing(&mut s, "type.ruby", json!({"remove": true})); + let p = json!({"text": "かんじ", "start": 0, "end": 6, "position": "below", "size": 60}); + records_one_step(&mut s, "type.ruby", p.clone()); + records_nothing(&mut s, "type.ruby", p); + s.execute("type.ruby", &json!({"remove": true})).unwrap(); + records_nothing(&mut s, "type.ruby", json!({"remove": true, "start": 0, "end": 6})); + s.execute("edit.undo", &json!({})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.ruby.len(), 1, "undo brings the ruby back"); +} + +/// `text.setStyle` (the Character panel) given the attributes the characters already have +/// records no undo step, with a range too. +#[test] +fn character_attributes_already_set_record_no_undo_step() { + let (mut s, _) = session_with("Headline", None); + records_nothing(&mut s, "text.setStyle", json!({"size": 24})); + s.execute("text.setStyle", &json!({"tracking": 50, "fill": "#ff0000"})).unwrap(); + records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "fill": "#ff0000"})); + records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "start": 0, "end": 4})); + s.execute("text.setStyle", &json!({"justify": "center"})).unwrap(); + records_nothing(&mut s, "text.setStyle", json!({"justify": "center"})); + records_one_step(&mut s, "text.setStyle", json!({"tracking": 80, "start": 0, "end": 4})); +} + +/// `text.setStyle` on a graph label that leaves the label's own text as it is but changes the +/// style its kind of label keeps still records one undo step; given again, it records none. +#[test] +fn a_kept_graph_label_style_change_alone_records_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4"; + let graph = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let doc = s.doc().unwrap().doc.clone(); + let legend = doc.node(graph).unwrap().children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap(); + let (label, size) = legend + .children() + .unwrap() + .iter() + .find_map(|c| match &c.kind { + NodeKind::Text(t) if t.plain_text() == "2025" => Some((c.id, t.runs[0].style.size)), + _ => None, + }) + .unwrap(); + let kept = |s: &Session| s.doc().unwrap().doc.node(graph).unwrap().extras().graph.as_deref().unwrap().label_styles.legend.clone(); + assert!(kept(&s).is_none()); + let p = json!({"ids": [label.0], "size": size}); + let before = state(&s); + s.execute("text.setStyle", &p).unwrap(); + let after = state(&s); + assert!(after.0.node(label) == before.0.node(label), "the label's own text changed"); + assert!(kept(&s).is_some(), "the legend keeps the style"); + assert_eq!(after.1, before.1 + 1, "the kept style change recorded no undo step"); + records_nothing(&mut s, "text.setStyle", p); +} + +/// `text.setText` with the text the type already has records no undo step; new text records one. +#[test] +fn the_same_text_records_no_undo_step() { + let (mut s, _) = session_with("Same words", None); + records_nothing(&mut s, "text.setText", json!({"text": "Same words"})); + records_one_step(&mut s, "text.setText", json!({"text": "Other words"})); +} + +/// Fit Headline run again on a headline it already fitted records no undo step; the first run +/// records one. +#[test] +fn fit_headline_again_records_no_undo_step() { + let (mut s, _) = session_with("HEADLINE\nbody text", Some(json!({"width": 300, "height": 120}))); + let start = state(&s); + let tracking = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].clone(); + let fitted = state(&s); + assert!(*fitted.0 != *start.0 && fitted.1 == start.1 + 1, "the first Fit Headline did not record one undo step"); + let before = state(&s); + assert_eq!(s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"], tracking); + let after = state(&s); + assert!(*after.0 == *before.0 && after.1 == before.1, "a second Fit Headline recorded an undo step"); +} + +/// Reshaping area type or type on a path by no distance, or moving a path handle to where it is, +/// records no undo step; a reshape that moves something records one. +#[test] +fn reshaping_type_by_nothing_records_no_undo_step() { + let (mut s, id) = session_with("Boxed words", Some(json!({"width": 200, "height": 100}))); + records_nothing(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 0, "dy": 0})); + records_one_step(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 20, "dy": 10})); + let path = + s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap(); + let on_path = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap()["id"].clone(); + records_nothing(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 0})); + records_one_step(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 20})); + let handle = json!({"id": on_path, "anchor": 0, "which": "out", "x": 100, "y": 250}); + records_one_step(&mut s, "text.setPathHandle", handle.clone()); + records_nothing(&mut s, "text.setPathHandle", handle); +} diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..b264c15cca 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8253,7 +8253,8 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane text reflows along it. `text.setPathHandle {id, subpath?, anchor, which: "in"|"out", x, y, independent?}` moves a direction handle of one of its anchors, as `path.setHandle` moves a path's (Direct Selection dragging a handle of the path, #3565), in one -undo step. +undo step. A call of `text.reshapeArea`, `text.reshapePath` or `text.setPathHandle` that changes nothing (`dx` and +`dy` both 0, or a handle moved to where it already is) adds no undo step and keeps redo. ```json {"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}} @@ -8318,7 +8319,10 @@ the end of the path), and `fit`, `fitScale` and `dynamicText` (Block is on) for Given for several area types (`ids`, or a selection of several), `text.areaOptions` changes only the options given on each one: each frame keeps its other options (it used to take the first frame's), and `fitMinPercent` alone changes only the frames that already shrink text to fit. Options that change nothing (the vertical alignment already set, say) add -no undo step and keep redo. +no undo step and keep redo. The same holds for `text.setText` when no object changes (the text it already has, in one +style, every paragraph with the first one's attributes and no ruby), `text.setStyle` when no character, paragraph or +graph label style changes, `type.ruby` with the same reading and options again or `remove` where there is no ruby, and +`text.fitHeadline` on a first line that already has the tracking it would set. Text that doesn't fit is drawn up to the last line that does, so a one-line headline in a frame lower than its line draws nothing. The commands that make type or change what it holds say so in their own reply: `text.create`, From 36bccf0f34a4d0d8e5f4c6c0502bca4f6d253607 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:24:40 -0800 Subject: [PATCH 44/89] 3D: Inflate with no volume copies only shapes that might fit its limits With no volume, each shape waits to be solved until its solid is taken, so its path is copied first. A shape with more contours or anchors than the solid's point limit allows is now refused before it is copied, with the error solving it would give, instead of being copied in full. The check counts each contour's anchors without reading them. --- crates/three-d/src/solid/inflate.rs | 50 +++++++++++++++++++++++------ crates/three-d/src/solid/tests.rs | 32 ++++++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 5ef7ba6d4d..5edf57a894 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -21,14 +21,35 @@ struct Puff { height: f64, } +/// The pieces each curve of `path` is sampled in for Inflate's solid ([`curve_pieces`]); an error +/// when its outline does not fit the solid's limits. +fn outline_pieces(path: &PathData, bounds: Rect, o: &Inflate) -> Result<u32, String> { + // The sides take one face for each edge of the outline in each slice; the grid's faces are + // counted as they are built. + curve_pieces(path, bounds.width().max(bounds.height()), if o.depth > 0.0 { slices(o) } else { 0 }, 0) +} + +/// Whether `path` has few enough anchors that [`curve_pieces`] might take it: fewer than +/// [`MAX_SOLID_POINTS`] contours, and at most three times as many anchors (a contour of one anchor +/// adds one, and every other takes at most two anchors for each segment it adds). Counts the +/// contours' anchors without reading them. +pub(super) fn might_fit(path: &PathData) -> bool { + let mut anchors = 0usize; + for (contours, sp) in path.subpaths.iter().enumerate() { + anchors = anchors.saturating_add(sp.anchors.len()); + if contours >= MAX_SOLID_POINTS || anchors > MAX_SOLID_POINTS.saturating_mul(3) { + return false; + } + } + true +} + /// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> { #[cfg(test)] PUFFED.with(|n| n.set(n.get() + 1)); - // The sides take one face for each edge of the outline in each slice; the grid's faces are - // counted as they are built. let size = bounds.width().max(bounds.height()); - let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?; + let most = outline_pieces(path, bounds, o)?; let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; if rings.is_empty() { return Ok(None); @@ -88,7 +109,18 @@ pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, opti #[cfg(test)] let later = later && !SOLVE_FIRST.with(std::cell::Cell::get); if later { - let shapes: Vec<(PathData, FillRule)> = shapes.iter().map(|(path, rule)| ((*path).clone(), *rule)).collect(); + // A shape is copied to be solved later only when its anchors are few enough for the + // solid's point limit; a larger one is refused now, with the error its puff gives. + let shapes: Vec<Result<(PathData, FillRule), String>> = shapes + .iter() + .map(|(path, rule)| { + check_bounds(bounds)?; + if !might_fit(path) { + outline_pieces(path, bounds, &o)?; + } + Ok(((*path).clone(), *rule)) + }) + .collect(); return Solids { pending: Pending::Later(shapes.into_iter()), bounds, o, view, reach: 0.0, keep }; } let puffs: Vec<Result<Option<Puff>, String>> = @@ -98,10 +130,11 @@ pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, opti } /// The shapes' puffs still to take: every one solved when the shapes were laid out, or each -/// shape's path and fill rule, its puff solved when its solid is taken. +/// shape's path and fill rule (or the error its bounds or outline already gives), its puff solved +/// when its solid is taken. enum Pending { Solved(std::vec::IntoIter<Result<Option<Puff>, String>>), - Later(std::vec::IntoIter<(PathData, FillRule)>), + Later(std::vec::IntoIter<Result<(PathData, FillRule), String>>), } /// The solids of [`inflate_faces`], one result for each shape in order, each built as it is taken. @@ -135,10 +168,7 @@ impl Iterator for Solids { fn next(&mut self) -> Option<Self::Item> { let p = match &mut self.pending { Pending::Solved(puffs) => puffs.next()?, - Pending::Later(shapes) => { - let (path, rule) = shapes.next()?; - check_bounds(self.bounds).and_then(|()| puff(&path, rule, self.bounds, &self.o)) - } + Pending::Later(shapes) => shapes.next()?.and_then(|(path, rule)| puff(&path, rule, self.bounds, &self.o)), }; Some(match p { Ok(Some(p)) => inflated(p, self.bounds, self.o, self.view, self.reach, self.keep), diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 3e516f7a57..a5984321ee 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -1097,6 +1097,38 @@ fn a_flat_inflate_solved_as_taken_matches_one_solved_first() { } } +/// With no volume, a shape with too many anchors or contours for the solid is refused before it +/// is kept for later, with the error that solving every puff first gives it. +#[test] +fn a_flat_inflate_refuses_a_shape_past_the_point_limit_as_one_solved_first() { + use super::inflate::might_fit; + let many = MAX_SOLID_POINTS * 3 + 10; + let anchors: Vec<Point> = (0..many).map(|i| Point::new((i % 200) as f64, (i / 200) as f64 / 10.0)).collect(); + let long = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&anchors, true)]); + let mut nan = anchors.clone(); + if let Some(last) = nan.last_mut() { + *last = Point::new(f64::NAN, 0.0); + } + let invalid = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&nan, true)]); + let dots = PathData::new((0..=MAX_SOLID_POINTS).map(|i| vectorcraft_geom::SubPath::polyline(&[Point::new(i as f64, 0.0)], false)).collect()); + let square = shape(&square(0.0, 0.0, 100.0)).0; + assert!(might_fit(&square) && !might_fit(&long) && !might_fit(&invalid) && !might_fit(&dots)); + let paths = [square, long, invalid, dots]; + let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect(); + let b = Rect::new(-10.0, -10.0, 250.0, 250.0); + let o = Inflate { volume: 0.0, ..Inflate::default() }; + let solids = |first: bool| { + SOLVE_FIRST.with(|s| s.set(first)); + let out: Vec<Result<build::Built, String>> = inflate_faces(&shapes, b, o, front(), build::Keep::Facets).collect(); + SOLVE_FIRST.with(|s| s.set(false)); + out + }; + let (taken, first) = (solids(false), solids(true)); + assert!(taken[0].as_ref().is_ok_and(|(f, ..)| !f.is_empty())); + assert!(taken[1..].iter().all(Result::is_err), "{:?}", taken[1..].iter().map(|r| r.as_ref().err()).collect::<Vec<_>>()); + assert_eq!(format!("{taken:?}"), format!("{first:?}")); +} + /// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole /// (the middle is filled, as the art is drawn); one running the other way does, and so does /// either under the even-odd rule. From 3c562254e8245f0333f0d9132d09259e49190635 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:31:55 -0800 Subject: [PATCH 45/89] Text wrap: Invert Wrap with an offset lets a line use all the room the grown shape holds With Invert Wrap and a positive offset, a line's band was cut into at most 64 pieces, and anchor cuts were dropped when a shape had more than 64 of them. Beside a notch the offset fills, a line could stop at the notch instead of at the grown edge: 20 pt short beside a 0.5625 pt notch with an offset of 1, and short beside sloped notch sides and tall bands. The grown shape is now measured exactly: the shape near the band is cut into trapezoids, each grown by the offset into a convex polygon, and the line keeps the x at which those polygons hold every height of its band. The work is capped; a shape that needs more steps than the cap, for example one whose edges cross one another tens of thousands of times, keeps the 64-piece measure, which keeps the line inside the grown shape. A band of no height exactly the offset past a shape's tip now holds a line too. --- crates/text/src/layout/region.rs | 398 ++++++++++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 2 files changed, 391 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 34c48942d5..3aa9883fa7 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -327,7 +327,7 @@ fn slant_boundaries(slants: &mut [Slant]) -> (Vec<(f64, f64)>, bool, Vec<Line>) } /// The most pieces a line's band is cut into to keep it inside an Invert Wrap shape grown by its -/// offset. +/// offset, when [`grown_band`] gives up, or out of a wrap shape shrunk by its offset. const INVERT_WRAP_PIECES: f64 = 64.0; /// The edges of `polys` that reach into the band `top..bottom` (`top <= bottom`), leaving out those @@ -523,6 +523,297 @@ fn eroded_cover(polys: &[Vec<Point>], top: f64, bottom: f64, d: f64) -> Vec<(f64 union_intervals(out) } +/// The most steps [`grown_band`] takes for one band and one wrap shape: edges, pairs of edges and +/// pairs of grown pieces looked at, and pieces looked at for each x it tests. +const MAX_INVERT_WRAP_WORK: usize = 1 << 20; + +#[cfg(test)] +thread_local! { + /// How many steps [`grown_band`] took on this thread. + static GROWN_BAND_WORK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; +} + +/// Adds `n` to `work`; false when that passes [`MAX_INVERT_WRAP_WORK`]. +fn spend(work: &mut usize, n: usize) -> bool { + *work = work.saturating_add(n); + #[cfg(test)] + GROWN_BAND_WORK.set(GROWN_BAND_WORK.get().saturating_add(n)); + *work <= MAX_INVERT_WRAP_WORK +} + +/// The x of the edge `p`–`q` (not horizontal) at `y`: the x of its end at that end's height. +fn edge_x(p: Point, q: Point, y: f64) -> f64 { + if y == p.y { + p.x + } else if y == q.y { + q.x + } else { + line_x(p, q, y) + } +} + +/// A trapezoid with a horizontal top and bottom: its top and bottom heights, then the x of its left +/// edge at each of them, then the x of its right edge at each of them. +type Trapezoid = (f64, f64, f64, f64, f64, f64); + +/// The shape `polys` (even-odd) between the heights `y0..y1` (`y0 < y1`) as trapezoids with +/// horizontal tops and bottoms, cut at each anchor's height and wherever two edges cross, whose union +/// (each trapezoid with its outline) is the shape with its outline there. Edges that lie along one +/// another give trapezoids that touch, and contours that cancel each other give none. `None` when +/// [`MAX_INVERT_WRAP_WORK`] runs out. +fn trapezoids(polys: &[Vec<Point>], y0: f64, y1: f64, work: &mut usize) -> Option<Vec<Trapezoid>> { + let mut edges: Vec<(Point, Point)> = polys + .iter() + .flat_map(|p| (0..p.len()).map(move |i| (p[i], p[(i + 1) % p.len()]))) + .filter(|(a, b)| a.y != b.y && a.y.max(b.y) > y0 && a.y.min(b.y) < y1) + .map(|(a, b)| if a.y < b.y { (a, b) } else { (b, a) }) + .collect(); + if !spend(work, edges.len()) { + return None; + } + edges.sort_by(|u, v| u.0.y.total_cmp(&v.0.y)); + let mut ys: Vec<f64> = edges.iter().flat_map(|(p, q)| [p.y, q.y]).filter(|&y| y > y0 && y < y1).chain([y0, y1]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup(); + let mut out = Vec::new(); + let (mut next, mut active) = (0, Vec::new()); + for w in ys.windows(2) { + let (a, b) = (w[0], w[1]); + // The edges that span the slab `a..b`: every anchor's height is a cut, so an edge that + // starts at or above `a` and ends below it reaches `b`. + while let Some(&e) = edges.get(next).filter(|e| e.0.y <= a) { + active.push(e); + next += 1; + } + active.retain(|e: &(Point, Point)| e.1.y >= b); + if !spend(work, active.len()) { + return None; + } + // The heights within the slab at which two edges cross: their order left to right differs + // at its top and bottom. + let mut cuts = vec![a, b]; + let ends: Vec<(f64, f64)> = active.iter().map(|&(p, q)| (edge_x(p, q, a), edge_x(p, q, b))).collect(); + let mut order: Vec<usize> = (0..ends.len()).collect(); + order.sort_by(|&i, &j| ends[i].0.total_cmp(&ends[j].0).then(ends[i].1.total_cmp(&ends[j].1))); + if order.windows(2).any(|w| ends[w[0]].1 > ends[w[1]].1) { + if !spend(work, ends.len() * ends.len()) { + return None; + } + for (i, &(sa, sb)) in ends.iter().enumerate() { + for &(ta, tb) in &ends[i + 1..] { + let (da, db) = (sa - ta, sb - tb); + if da * db < 0.0 { + let y = a + (b - a) * (da / (da - db)); + if y > a && y < b { + cuts.push(y); + } + } + } + } + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + } + for c in cuts.windows(2) { + let (c, d) = (c[0], c[1]); + // No two edges cross within `c..d`, so their order at its middle holds throughout, and + // the shape (even-odd) lies between the first and second, the third and fourth, and so on. + let mid = (c + d) * 0.5; + let mut xs: Vec<(f64, f64, f64)> = active.iter().map(|&(p, q)| (line_x(p, q, mid), edge_x(p, q, c), edge_x(p, q, d))).collect(); + if !spend(work, xs.len()) { + return None; + } + xs.sort_by(|u, v| u.0.total_cmp(&v.0)); + for pair in xs.as_chunks::<2>().0 { + let ((_, l0, l1), (_, r0, r1)) = (pair[0], pair[1]); + if r0 > l0 || r1 > l1 { + out.push((c, d, l0, l1, r0, r1)); + } + } + } + } + Some(out) +} + +/// A convex polygon as its lower and upper outlines (the least and the greatest y at each x), each +/// left to right from its least x to its greatest, with its x range and y range. +struct Convex { + lower: Vec<Point>, + upper: Vec<Point>, + xs: (f64, f64), + ys: (f64, f64), +} + +impl Convex { + /// The convex hull of `pts`. + fn hull(mut pts: Vec<Point>) -> Convex { + let cross = |o: Point, a: Point, b: Point| (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + let chain = |pts: &[Point], turns: fn(f64) -> bool| { + let mut out: Vec<Point> = Vec::with_capacity(pts.len()); + for &p in pts { + while let [.., o, a] = out[..] { + if turns(cross(o, a, p)) { + break; + } + out.pop(); + } + out.push(p); + } + // The points at the greatest x after the first are on the other outline. + while let [.., a, b] = out[..] { + if a.x != b.x { + break; + } + out.pop(); + } + out + }; + pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(p.y.total_cmp(&q.y))); + let lower = chain(&pts, |c| c > 0.0); + pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(q.y.total_cmp(&p.y))); + let upper = chain(&pts, |c| c < 0.0); + let xs = (lower.first().map_or(f64::NAN, |p| p.x), lower.last().map_or(f64::NAN, |p| p.x)); + let ys = (lower.iter().fold(f64::INFINITY, |m, p| m.min(p.y)), upper.iter().fold(f64::NEG_INFINITY, |m, p| m.max(p.y))); + Convex { lower, upper, xs, ys } + } + + /// The y range of the polygon at `x`, within its x range. + fn at(&self, x: f64) -> (f64, f64) { + (chain_y(&self.lower, x), chain_y(&self.upper, x)) + } +} + +/// The y of the outline `chain` (left to right, no two points at one x) at `x`, within its x range. +fn chain_y(chain: &[Point], x: f64) -> f64 { + let i = chain.partition_point(|p| p.x < x); + match (i.checked_sub(1).and_then(|h| chain.get(h)), chain.get(i)) { + (Some(p), Some(q)) => p.y + (x - p.x) / (q.x - p.x) * (q.y - p.y), + (None, Some(q)) => q.y, + (Some(p), None) => p.y, + (None, None) => f64::NAN, + } +} + +/// The x at which some segment of `a` and some segment of `b` (outlines left to right) are at the +/// same y, strictly between the ends of the x range they share. +fn chain_crossings(a: &[Point], b: &[Point], out: &mut Vec<f64>, work: &mut usize) -> bool { + for s in a.windows(2) { + let first = b.partition_point(|q| q.x <= s[0].x).saturating_sub(1); + for t in b[first..].windows(2).take_while(|t| t[0].x < s[1].x) { + if !spend(work, 1) { + return false; + } + let (x0, x1) = (s[0].x.max(t[0].x), s[1].x.min(t[1].x)); + if x1 <= x0 { + continue; + } + let (d0, d1) = (chain_y(s, x0) - chain_y(t, x0), chain_y(s, x1) - chain_y(t, x1)); + if d0 * d1 < 0.0 { + out.push(x0 + (x1 - x0) * (d0 / (d0 - d1))); + } + } + } + true +} + +/// The x within `lo..hi` at which the shape `polys` (even-odd) grown by `off` (> 0) across and +/// along holds every height of the band `top..bottom` (`top <= bottom`): the x whose vertical line +/// across the band has each of its points within `off`, across and along, of the shape or its +/// outline. The shape from `off` above the band to `off` below it, and on to the nearest anchors +/// past those heights, is cut into [`trapezoids`]; each +/// grown by `off` is a convex polygon, and the line at an x lies in their union when the y ranges +/// they give at that x join up across the band. Those ranges change only at the polygons' corners +/// and where their outlines cross each other or the band's top or bottom, so the line is tested +/// once between each two of those x. `None` when that takes more than [`MAX_INVERT_WRAP_WORK`] +/// steps. +fn grown_band(polys: &[Vec<Point>], off: f64, top: f64, bottom: f64, (lo, hi): (f64, f64)) -> Option<Vec<(f64, f64)>> { + let mut work = 0; + // The trapezoids reach on to the nearest anchor past `off` above and below the band, so that + // the shape's outline exactly `off` from the band counts. + let (y0, y1) = (top - off, bottom + off); + let ys = || polys.iter().flatten().map(|p| p.y); + let below = ys().filter(|&y| y < y0).fold(f64::NEG_INFINITY, f64::max); + let above = ys().filter(|&y| y > y1).fold(f64::INFINITY, f64::min); + let traps = trapezoids(polys, if below.is_finite() { below } else { y0 }, if above.is_finite() { above } else { y1 }, &mut work)?; + let mut grown: Vec<Convex> = traps + .into_iter() + .filter(|&(_, _, l0, l1, r0, r1)| l0.min(l1) - off < hi && r0.max(r1) + off > lo) + .map(|(y0, y1, l0, l1, r0, r1)| { + let corners = [(l0, y0), (r0, y0), (l1, y1), (r1, y1)]; + let pts = corners + .into_iter() + .flat_map(|(x, y)| [(-off, -off), (off, -off), (-off, off), (off, off)].map(|(dx, dy)| Point::new(x + dx, y + dy))); + Convex::hull(pts.collect()) + }) + .collect(); + grown.sort_by(|p, q| p.xs.0.total_cmp(&q.xs.0)); + // The x at which the line's place among the polygons can change. + let mut xs = vec![lo, hi]; + for (j, p) in grown.iter().enumerate() { + xs.extend(p.lower.iter().chain(&p.upper).map(|v| v.x)); + for chain in [&p.lower, &p.upper] { + for s in chain.windows(2) { + for y in [top, bottom] { + let (d0, d1) = (s[0].y - y, s[1].y - y); + if d0 * d1 < 0.0 { + xs.push(s[0].x + (s[1].x - s[0].x) * (d0 / (d0 - d1))); + } + } + } + } + let ((_, x1), (y0, y1)) = (p.xs, p.ys); + for q in grown[j + 1..].iter().take_while(|q| q.xs.0 < x1) { + if !spend(&mut work, 1) { + return None; + } + let (v0, v1) = q.ys; + if v0 <= y1 + && y0 <= v1 + && !(chain_crossings(&p.upper, &q.lower, &mut xs, &mut work) && chain_crossings(&q.upper, &p.lower, &mut xs, &mut work)) + { + return None; + } + } + } + xs.retain(|&x| x >= lo && x <= hi); + xs.sort_by(f64::total_cmp); + xs.dedup(); + // Test the line between each two of those x, walking left to right with the polygons whose x + // range holds it. + let mut out: Vec<(f64, f64)> = Vec::new(); + let (mut next, mut active, mut ranges): (usize, Vec<&Convex>, Vec<(f64, f64)>) = (0, Vec::new(), Vec::new()); + for w in xs.windows(2) { + let x = (w[0] + w[1]) * 0.5; + while let Some(p) = grown.get(next).filter(|p| p.xs.0 <= x) { + active.push(p); + next += 1; + } + active.retain(|p| p.xs.1 >= x); + if !spend(&mut work, active.len()) { + return None; + } + ranges.clear(); + ranges.extend(active.iter().map(|p| p.at(x))); + ranges.sort_by(|u, v| u.0.total_cmp(&v.0)); + // The line is held from the band's top to `reach`, up to the first range that starts past + // it; a band of no height is held when a range reaches it. + let (mut reach, mut met) = (top, false); + for &(a, b) in &ranges { + if a > reach { + break; + } + reach = reach.max(b); + met |= b >= top; + } + if met && reach >= bottom { + match out.last_mut() { + Some(l) if l.1 >= w[0] => l.1 = w[1], + _ => out.push((w[0], w[1])), + } + } + } + Some(out) +} + impl Region { pub(super) fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec<Region> { // A frame out of range has no cells, and a wrap shape out of range is left out. @@ -595,10 +886,20 @@ impl Region { } else { subtract_intervals(&iv, &pad(eroded_cover(polys, top, bottom, -off))) } + } else if let Some(held) = (*invert && *off > 0.0) + .then(|| iv.first().zip(iv.last()).and_then(|(f, l)| grown_band(polys, *off, top, bottom, (f.0 - inset, l.1 + inset)))) + .flatten() + { + // The x at which the shape grown by the offset holds the whole band. + intersect_intervals(&iv, &shrink(held)) } else if *invert { - // The band is cut into pieces. Each height of a piece `y0..y1` is within the offset - // of some height from `y0` plus the offset to `y1` less the offset, so an x the - // shape holds at all of those heights is inside the grown shape over the piece. In + // With no offset, or when measuring the grown shape exactly takes more than + // [`MAX_INVERT_WRAP_WORK`] steps, the band is cut into pieces, which can leave a line + // shorter than the grown shape allows, never longer. + // + // Each height of a piece `y0..y1` is within the offset of some height from `y0` + // plus the offset to `y1` less the offset, so an x the shape holds at all of those + // heights is inside the grown shape over the piece. In // a piece less than twice the offset tall, each height of the piece is within the // offset of every height from `y1` less the offset to `y0` plus the offset, so an x // the shape holds at any of them is. Text within the offset of such an x, for every @@ -1166,8 +1467,8 @@ fn intersecting_intervals_looks_at_each_interval_once() { } /// A line in area type with an Invert Wrap comb of 500 teeth and an offset keeps a span inside -/// each tooth, left to right, through every piece of its band, and intersecting the spans with each -/// piece looks at each span once. +/// each tooth, left to right, and measuring the grown comb takes a few dozen steps for each tooth, +/// not one for each pair of teeth. #[cfg(test)] #[test] fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { @@ -1179,11 +1480,92 @@ fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 0.25, invert: true } }; let cells = Region::cells(&frame, &LayoutOptions::default(), &[wrap]); INTERSECT_PAIRS.set(0); + GROWN_BAND_WORK.set(0); let got = cells[0].spans(40.0, 60.0); let want: Vec<(f64, f64)> = (0..500).map(|k| f64::from(k) * 2.0).map(|x| ((x - 0.25).max(0.0), x + 1.25)).collect(); assert_spans(&got, &want); - // At most 64 pieces, each looking at no more than the spans so far and the tooth spans. - assert!(INTERSECT_PAIRS.get() <= 64 * (500 + 500), "{} pairs", INTERSECT_PAIRS.get()); + // The line's one span is intersected once with the 500 spans the grown comb holds. + assert!(INTERSECT_PAIRS.get() <= 1 + 500, "{} pairs", INTERSECT_PAIRS.get()); + assert!(GROWN_BAND_WORK.get() <= 40 * 500, "{} steps", GROWN_BAND_WORK.get()); +} + +/// The spans of a 120 pt wide, 100 pt tall frame over the band `top..bottom` with one Invert Wrap +/// object through `contour` and `offset`. +#[cfg(test)] +fn invert_wrap_spans(contour: &[(f64, f64)], offset: f64, top: f64, bottom: f64) -> Vec<(f64, f64)> { + let frame = Rect::new(0.0, 0.0, 120.0, 100.0).to_path(0.1); + let path = PathData::from_bezpath(&closed(&[contour])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset, invert: true } }; + Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(top, bottom) +} + +/// The 100 pt square whose right edge steps in to x = 80 from `y0` to `y1`, with 79 more anchors +/// along its left edge, which leave its shape as it is. +#[cfg(test)] +fn notched_square(y0: f64, y1: f64) -> Vec<(f64, f64)> { + let mut contour = vec![(0.0, 0.0), (100.0, 0.0), (100.0, y0), (80.0, y0), (80.0, y1), (100.0, y1), (100.0, 100.0), (0.0, 100.0)]; + contour.extend((1..80).rev().map(|i| (0.0, 1.25 * f64::from(i)))); + contour +} + +/// With Invert Wrap and an offset, a line beside a notch the offset fills reaches as far as the +/// grown shape does at every height of its band, however tall the band and however many anchors +/// the shape has: a notch 0.5625 pt tall with an offset of 1, and one 0.4 pt tall with an offset +/// of 0.25 over a band of 100 pt. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_lets_a_line_beside_a_notch_reach_the_grown_shape() { + assert_spans(&invert_wrap_spans(¬ched_square(50.5, 51.0625), 1.0, 0.0, 100.0), &[(0.0, 101.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.6, 51.0), 0.25, 0.0, 100.0), &[(0.0, 100.25)]); + // A band of no height in the notch, and one in a notch the offset does not fill. + assert_spans(&invert_wrap_spans(¬ched_square(50.5, 51.0625), 1.0, 50.7, 50.7), &[(0.0, 101.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.0, 53.0), 1.0, 0.0, 100.0), &[(0.0, 81.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.0, 53.0), 1.0, 51.5, 51.5), &[(0.0, 81.0)]); +} + +/// With Invert Wrap and an offset, a line beside a notch with sloped sides reaches as far as the +/// grown shape does at the notch's tip: 1 pt above and below the tip, each side is 8 pt from it. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_lets_a_line_beside_a_sloped_notch_reach_the_grown_shape() { + let notch = [(0.0, 0.0), (100.0, 0.0), (100.0, 45.15), (60.0, 50.15), (100.0, 55.15), (100.0, 100.0), (0.0, 100.0)]; + assert_spans(&invert_wrap_spans(¬ch, 1.0, 40.0, 60.0), &[(0.0, 69.0)]); + assert_spans(&invert_wrap_spans(¬ch, 5.0, 40.0, 60.0), &[(0.0, 105.0)]); +} + +/// With Invert Wrap and an offset, a band of no height exactly the offset past a wrap shape's +/// bottom edge or its tip still holds a line as wide as the grown edge or tip. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_holds_a_band_exactly_the_offset_past_the_shape() { + let bar = [(20.0, 0.0), (60.0, 0.0), (60.0, 10.0), (20.0, 10.0)]; + assert_spans(&invert_wrap_spans(&bar, 1.0, 11.0, 11.0), &[(19.0, 61.0)]); + assert!(invert_wrap_spans(&bar, 1.0, 11.5, 11.5).is_empty()); + let tip = [(20.0, 0.0), (80.0, 0.0), (50.0, 10.0)]; + assert_spans(&invert_wrap_spans(&tip, 1.0, 11.0, 11.0), &[(49.0, 51.0)]); + let tip = [(20.0, 20.0), (50.0, 10.0), (80.0, 20.0)]; + assert_spans(&invert_wrap_spans(&tip, 1.0, 9.0, 9.0), &[(49.0, 51.0)]); +} + +/// A wrap shape whose edges cross one another tens of thousands of times takes no more than +/// [`MAX_INVERT_WRAP_WORK`] steps to measure exactly; it is then cut into pieces, and the line +/// still stays inside the shape grown by the offset. An offset that is not a number is 0. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_bounds_its_work_on_a_tangled_shape() { + let star: Vec<Point> = (0..301) + .map(|k| f64::from(k * 150) * std::f64::consts::TAU / 301.0) + .map(|a| Point::new(50.0 + 45.0 * a.cos(), 50.0 + 45.0 * a.sin())) + .collect(); + GROWN_BAND_WORK.set(0); + assert!(grown_band(std::slice::from_ref(&star), 1.0, 10.0, 90.0, (0.0, 120.0)).is_none()); + assert!(GROWN_BAND_WORK.get() <= 2 * MAX_INVERT_WRAP_WORK, "{} steps", GROWN_BAND_WORK.get()); + let contour: Vec<(f64, f64)> = star.iter().map(|p| (p.x, p.y)).collect(); + let spans = invert_wrap_spans(&contour, 1.0, 10.0, 90.0); + assert!(spans.iter().all(|&(a, b)| a >= 4.0 && b <= 96.0), "{spans:?}"); + let square = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]; + assert_spans(&invert_wrap_spans(&square, f64::NAN, 45.0, 55.0), &invert_wrap_spans(&square, 0.0, 45.0, 55.0)); + assert_spans(&invert_wrap_spans(&square, 0.0, 45.0, 55.0), &[(40.0, 60.0)]); } /// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..c2b981e329 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1324,7 +1324,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`). In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). In area type whose frame has several contours, a line runs across an edge two contours share only when their edges lie exactly on one line, so a gap between contours keeps lines from crossing it, even a gap one unit in the last place wide near the edge of the canvas; an edge divided by an anchor exactly on it still lets lines cross (`layout::region::separate_slanted_contours_keep_a_four_ulp_gap_near_canvas_limit`, `shared_slanted_edge_still_joins_near_canvas_limit`, `area_type_sets_no_line_across_a_four_ulp_gap_between_slanted_contours`). - With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests). + With Invert Wrap and a positive Offset, a line uses all the room the wrap shape grown by the Offset (across and along) holds at every height of its band: beside a notch the Offset fills, the line reaches the grown edge, whatever the band's height, the slope of the notch's sides or the number of anchors on the shape (VectorCraft's own measure). Measuring a wrap shape of many parts (a comb of 500 teeth) takes a few dozen steps per part, not one per pair of parts. The measure stops after a fixed number of steps; a shape that needs more, for example one whose edges cross one another tens of thousands of times, has its band cut into at most 64 pieces instead, which keeps the line inside the grown shape but can leave it shorter than the grown shape allows (`crates/text/src/layout/region.rs` tests). Text Wrap Options › Offset is a distance field in the document's units: a bare number is in those units, a typed unit ("12 pt") is read as typed, and OK sends points. In a document in picas, a bare number follows Preferences › Units › Numbers Without Units Are Points as the other length fields do: it is in points while that preference is on (the default) and in picas while it is off (`dialogs::command::tests::text_wrap_offset_in_picas_follows_numbers_without_units_are_points`). An Offset typed outside -1000 to 1000 pt, VectorCraft's own range for the command, or text that doesn't read, is refused: the field flashes and the dialog stays open, whether Enter or a click on OK ends the entry; in every dialog drawn by the shared dialog window, a click on OK no longer confirms it when the distance field it takes the focus from refuses its text (`dialogs::command::tests::text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range`). Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/enter-values-in-panels-and-dialog-boxes.html). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. From 10382a2f37de53ec5fa152c505b2725964d256d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:51:14 -0800 Subject: [PATCH 46/89] Export: symbol art dropped from the budget still counts toward it An instance whose baked art passed the export's placed symbol art budget, or drew too deep, gave its count back, so any number of instances could each bake their art in full and be dropped without the work counting. - Art baked and then dropped is now counted with the placed art against the budget, both before more art is baked and when baked art is checked. - While an instance's art bakes, the art's objects before baking are held in the budget, so instances nested in it are placed only within what is left. Documents whose placed art fits the budget export as before. --- crates/effects/src/bake.rs | 45 +++++++++++------- crates/effects/src/bake/tests.rs | 78 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f191f8ce89..7374754537 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -121,6 +121,10 @@ thread_local! { /// The objects in the baked art of the instances that [`bake_node`] has replaced by their /// placed art for one export ([`placed_symbol_art`]), counted against [`Placing::most_nodes`]. static PLACED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; + /// The objects in the baked art of the instances that [`bake_node`] baked for one export and + /// then left as they are ([`placed_fits`] refused the art), counted with [`PLACED_NODES`] + /// against [`Placing::most_nodes`] before more art is baked. Never given back. + static DROPPED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer /// is 1 deep. static LEVEL: std::cell::Cell<usize> = const { std::cell::Cell::new(1) }; @@ -179,17 +183,18 @@ fn unwritten(n: &Node) -> bool { } /// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, -/// when the art placed for the export so far holds `spent` objects? `Some` with the objects -/// placed then, when its objects (opacity masks' art included) fit within +/// when the art placed for the export so far holds `spent` objects and the art baked for +/// instances left as they are holds `dropped` ([`DROPPED_NODES`])? `Some` with the +/// objects placed then, when its objects (opacity masks' art included) fit with those within /// [`Placing::most_nodes`] and nothing in it, the symbols and patterns it uses included, draws /// deeper than [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`]. -fn placed_fits(baked: &Node, spent: u64) -> Option<u64> { +fn placed_fits(baked: &Node, spent: u64, dropped: u64) -> Option<u64> { PLACING.with(|p| { let p = p.borrow(); let p = p.as_ref()?; let spent = spent.saturating_add(baked.count_with_masks() as u64); let deepest = p.heights.deepest(baked, LEVEL.with(|c| c.get())); - (spent <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) + (spent.saturating_add(dropped) <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) }) } @@ -246,10 +251,12 @@ fn holds_placed_symbol(n: &Node) -> bool { /// instance that scales its art does not scale what its effects do. `None` for other objects, /// for instances the export does not write ([`unwritten`], hidden or inside such an object), for /// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art -/// as placed, before its effects are baked, would already take the art placed for the export -/// past [`Placing::most_nodes`]; those instances stay as they are. [`bake_node`] checks the baked -/// art with [`placed_fits`]. -fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { +/// as placed, before its effects are baked, would already take the art placed for the export, +/// with the art baked for instances left as they are ([`DROPPED_NODES`]), past +/// [`Placing::most_nodes`]; those instances stay as they are. `Some` with the objects in the art +/// as placed, before its effects are baked. [`bake_node`] checks the baked art with +/// [`placed_fits`]. +fn placed_symbol_art(d: &Document, n: &Node) -> Option<(Node, u64)> { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || UNWRITTEN.with(|c| c.get()) || unwritten(n) { @@ -262,13 +269,13 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option<Node> { Some(m) => m.most_placed_unclipped(&art, d), None => art.count_with_masks() as u64, }; - if PLACED_NODES.with(|c| c.get()).saturating_add(most) > most_nodes { + if PLACED_NODES.with(|c| c.get()).saturating_add(DROPPED_NODES.with(|c| c.get())).saturating_add(most) > most_nodes { return None; } let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; let mut g = dressed(Node::group(n.id, vec![Arc::new(placed)]), n); g.extras_mut().attrs = n.extras().attrs.clone(); - Some(g) + Some((g, most)) } /// `n` with its geometry effects evaluated (`None` = unchanged). @@ -346,18 +353,22 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> { // Symbol instances whose art draws live effects, when the caller asks for them: the art // placed, then baked. When the baked art passes the export's budget, or would draw too deep // where the instance is, it is dropped with the art placed for the instances inside it, and - // the instance stays as it is. - if let Some(m) = placed_symbol_art(d, n) { + // the instance stays as it is. While the art bakes, its objects before baking are held in + // the budget, so the instances inside it are placed only within what is left; art baked and + // then dropped still counts against the budget for the instances after it. + if let Some((m, most)) = placed_symbol_art(d, n) { let spent = PLACED_NODES.with(|c| c.get()); + PLACED_NODES.with(|c| c.set(spent.saturating_add(most))); INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); let baked = bake_node(d, &m); INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); let baked = baked.unwrap_or(m); - if let Some(now) = placed_fits(&baked, spent) { + if let Some(now) = placed_fits(&baked, spent, DROPPED_NODES.with(|c| c.get())) { PLACED_NODES.with(|c| c.set(now)); return Some(baked); } PLACED_NODES.with(|c| c.set(spent)); + DROPPED_NODES.with(|c| c.set(c.get().saturating_add(baked.count_with_masks() as u64))); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning @@ -895,14 +906,15 @@ pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool, layers: } /// Restores what [`bake`] changes in the thread's baking state when it ends. -struct Restore(Option<Rc<Placing>>, u64, usize, bool); +struct Restore(Option<Rc<Placing>>, u64, u64, usize, bool); impl Drop for Restore { fn drop(&mut self) { PLACING.with(|p| *p.borrow_mut() = self.0.take()); PLACED_NODES.with(|c| c.set(self.1)); - LEVEL.with(|c| c.set(self.2)); - UNWRITTEN.with(|c| c.set(self.3)); + DROPPED_NODES.with(|c| c.set(self.2)); + LEVEL.with(|c| c.set(self.3)); + UNWRITTEN.with(|c| c.set(self.4)); } } @@ -924,6 +936,7 @@ fn bake(doc: &Document, placing: Option<(bool, u64, WrittenLayers)>) -> Option<D let _restore = Restore( PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0)), + DROPPED_NODES.with(|c| c.replace(0)), LEVEL.with(|c| c.replace(1)), UNWRITTEN.with(|c| c.replace(false)), ); diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index ed7699651d..5afa222d2e 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1252,6 +1252,51 @@ fn placing_symbols_counts_nested_art_once() { assert!(is_instance(&short, 900), "the outer instance stays as it is"); } +/// While an instance's art bakes, the art's own objects are held in the budget: an instance +/// nested in it whose art does not fit in what is left stays as it is, and the outer instance is +/// placed around it. +#[test] +fn placing_symbols_holds_the_outer_art_while_it_bakes() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", Node::group(NodeId(803), vec![Arc::new(gradient_copies(800))])); + let mut art: Vec<Arc<Node>> = (0..5) + .map(|i| Arc::new(Node::path(NodeId(810 + i), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()))) + .collect(); + art.push(Arc::new(instance(802, "Inner", Affine::IDENTITY))); + let outer = Node::group(NodeId(801), art); + // The outer instance's group around its art, with the inner instance left as it is. + let around = 1 + outer.count_with_masks() as u64; + add_symbol(&mut d, "Outer", outer); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let inner = all.node(NodeId(802)).map_or(0, |n| n.count_with_masks() as u64); + assert!(inner > 2 && inner <= around, "the inner art alone fits: {inner} <= {around}"); + let out = bake(&d, Some((true, around, ALL))).unwrap(); + assert!(!is_instance(&out, 900) && is_instance(&out, 802), "the outer instance is placed around the inner one"); +} + +/// Art baked for an instance and then left out, because it passes the budget, still counts: an +/// instance after it whose art would fit in the budget alone stays as it is, and a budget with +/// room for both places both. +#[test] +fn placing_symbols_counts_the_art_it_drops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(901, "Small", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(900, "Big", Affine::IDENTITY)).unwrap(); + add_symbol(&mut d, "Big", gradient_copies(800)); + add_symbol(&mut d, "Small", moved_square(810)); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let count = |id: u64| all.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (big, small) = (count(900), count(901)); + assert!(small < big, "{small} < {big}"); + let short = bake(&d, Some((true, big - 1, ALL))).unwrap(); + assert!(is_instance(&short, 900), "the big art passes {}", big - 1); + assert!(is_instance(&short, 901), "the big art's {big} objects count, so the small art does not fit"); + let both = bake(&d, Some((true, big + small, ALL))).unwrap(); + assert!(!is_instance(&both, 900) && !is_instance(&both, 901)); +} + /// Instances the export does not write are not placed and do not count against the budget: with /// room for one instance's baked art, hidden instances, an instance in a hidden group, one on a /// template layer, one on a layer that does not print and one on a hidden layer (when the export @@ -1329,3 +1374,36 @@ fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); assert!(!is_instance(&bake_document_placing_symbols(&shallow, true, ALL).unwrap(), 900)); } + +/// Art baked for an instance and then left out because it draws too deep still counts when the +/// baked art of a later instance is checked: that art fits before its effects add copies, but +/// not once they do. +#[test] +fn placing_symbols_counts_art_dropped_for_depth_against_later_baked_art() { + use vectorcraft_geom::Affine; + // The chain of placing_symbols_keeps_the_baked_art_within_the_drawn_depth, then F, whose + // square becomes a group of copies once baked. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + add_symbol(&mut d, "F", gradient_copies(6000)); + let layer = d.layers[0].id; + d.insert(Some(layer), 1, instance(901, "F", Affine::IDENTITY)).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + assert!(is_instance(&all, 4100) && !is_instance(&all, 900) && !is_instance(&all, 901)); + let count = |d: &Document, id: u64| d.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (a, f) = (count(&all, 900), count(&all, 901)); + // E's baked art, as placed where nothing above it draws deep. + let mut shallow = doc_with(instance(4100, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + let e = count(&bake(&shallow, Some((true, u64::MAX, ALL))).unwrap(), 4100); + assert!(f > 2, "F's copies are objects of their own: {f}"); + let short = bake(&d, Some((true, a + e + f - 1, ALL))).unwrap(); + assert!(!is_instance(&short, 900) && is_instance(&short, 4100), "A to D are placed, E is not"); + assert!(is_instance(&short, 901), "E's dropped {e} objects count, so F's baked {f} do not fit"); + let room = bake(&d, Some((true, a + e + f, ALL))).unwrap(); + assert!(!is_instance(&room, 900) && !is_instance(&room, 901)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 69a13ddc57..0ebb9b8ae3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1575,7 +1575,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A linked instance's PDF link covers its art where the effects draw it. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`, `a_linked_instance_keeps_its_link_when_its_art_has_effects`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A linked instance's PDF link covers its art where the effects draw it. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Art baked for an instance that then stays an instance still counts toward that limit, and while an instance's art bakes, the art's own objects are held in the limit for the instances nested in it. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_holds_the_outer_art_while_it_bakes`, `placing_symbols_counts_the_art_it_drops`, `placing_symbols_counts_art_dropped_for_depth_against_later_baked_art`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`, `a_linked_instance_keeps_its_link_when_its_art_has_effects`). A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). From 5114deb5782f5ba46f0a17ea509d6e1451d0f295 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 04:12:58 -0800 Subject: [PATCH 47/89] i18n: Brazilian Portuguese spells Relink correctly and calls process colors "cor de processo" in two messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Relink", "Relink…" and "Relink to Folder…" read "Revinculcar"; they now use "Revincular", as every other Relink row in the catalog does. Two messages about process colors said "cor de seleção" (selection color); they now say "cor de processo", matching the Process Color label. A new i18n test checks both. --- crates/ui-egui/src/i18n/pt-br.tsv | 10 +++++----- crates/ui-egui/src/i18n/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 90825f967f..55abc8e236 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2393,8 +2393,8 @@ digits Malayalam Malaiala Release Opacity Mask Liberar Máscara de Opacidade Release Selection Liberar Seleção Release with Perspective Liberar com Perspectiva - Relink Revinculcar - Relink to Folder… Revinculcar Pasta… + Relink Revincular + Relink to Folder… Revincular Pasta… Each Link Separately Cada Vínculo Separadamente Selected Instances Instâncias Selecionadas All Instances Todas as Instâncias @@ -2403,7 +2403,7 @@ digits Malayalam Malaiala @msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each e paths andam juntos: paths lista entradas com ids e path, no lugar de ids, path e folder @msg paths entry {i} needs path and ids (object ids no other entry has) a entrada {i} de paths precisa de path e ids (ids de objeto que nenhuma outra entrada tenha) @msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder recebe path: os outros vínculos ausentes são procurados na pasta desse arquivo - Relink… Revinculcar… + Relink… Revincular… Reload Plug-ins Recarregar Plug-ins Remove Remover Remove Anchor Points Remover Pontos de Ancoragem @@ -3962,10 +3962,10 @@ axis Both Ambos @msg `{k}` must be [x, y] `{k}` deve ser [x, y] @msg `{name}` has no options `{name}` não tem opções @msg `{name}` is a built-in workspace; choose another name `{name}` é um espaço de trabalho integrado; escolha outro nome -@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` é uma cor de seleção: os matizes se aplicam a cores globais e especiais +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` é uma cor de processo: os matizes se aplicam a cores globais e especiais @msg `{name}` is no recovery entry name `{name}` não é o nome de uma entrada de recuperação @msg `{name}` isn't a colour swatch `{name}` não é uma amostra de cor -@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white uma cor de seleção não pode colorir uma imagem em escala de cinza com transparência: use uma cor especial, preto ou branco +@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white uma cor de processo não pode colorir uma imagem em escala de cinza com transparência: use uma cor especial, preto ou branco @msg `{path}` must be a bool or an object of bools `{path}` deve ser um booleano ou um objeto de booleanos @msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` não é uma cor (#rrggbb ou o nome de uma predefinição) @msg `{tool}` isn't a Liquify tool `{tool}` não é uma ferramenta de Liquidificar diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 4f9f03e5f6..2b6e7c9a7f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -334,6 +334,27 @@ fn catalan_reads_as_catalan() { assert_eq!(message(ca, nested), "l’ordre `object.group` no està disponible ara mateix: no hi ha res seleccionat"); } +/// Brazilian Portuguese spells Relink "Revincular", and no row calls a process color a +/// selection color ("cor de seleção"). +#[test] +fn brazilian_portuguese_spells_relink_and_process_color() { + let pt = Lang::from_code("pt-br").expect("pt-br registered"); + let (entries, _) = parse_entries(pt.0.source); + let mut wrong = Vec::new(); + for (_, src, translation) in &entries { + let portuguese = translation.to_lowercase(); + if portuguese.contains("revinculcar") { + wrong.push(format!("Relink in {src:?} -> {translation:?}")); + } + if src.to_lowercase().contains("process colo") && (portuguese.contains("cor de seleção") || portuguese.contains("cores de seleção")) { + wrong.push(format!("process color in {src:?} -> {translation:?}")); + } + } + assert!(wrong.is_empty(), "{} Brazilian Portuguese rows are wrong:\n{}", wrong.len(), wrong.join("\n")); + assert_eq!(tr(pt, "Relink…"), "Revincular…"); + assert!(message(pt, "`{name}` is a process colour: tints apply to global and spot colours").to_lowercase().contains("cor de processo")); +} + #[test] fn font_height_reference_labels_are_in_complete_catalogs() { for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { From fc47e4782b56f3b0649c2b1a3f4fb519607cf7d1 Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:23:22 +0100 Subject: [PATCH 48/89] Pencil: Use Precise Cursors shows the plain crosshair again; a far close is a straight segment --- crates/engine/src/cmd/draw2/freehand.rs | 8 ++++++++ crates/engine/src/tests/draw2.rs | 16 ++++++++++++++++ crates/engine/src/tests/prefs.rs | 12 +++++------- crates/tools/src/cursor.rs | 9 ++++++--- crates/tools/src/cursors.toml | 5 ----- crates/tools/src/draw2/gesture/tests.rs | 4 ++-- crates/tools/src/lib.rs | 4 ++-- docs/target-app-parity.md | 4 ++-- docs/ui-parity.md | 4 ++-- 9 files changed, 43 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index b8dd92eb6a..401bdd2977 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -253,7 +253,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { a0.h_in = l.h_in; sp.closed = true; } else if closing && !ends_on_first && sp.anchors.len() > 2 { + // Closed from away from the other end: a straight segment back to it, whatever + // handles the open ends carried. sp.closed = true; + if let Some(l) = sp.anchors.last_mut() { + l.h_out = l.p; + } + if let Some(f) = sp.anchors.first_mut() { + f.h_in = f.p; + } } for a in &mut sp.anchors { *a = Anchor::with_handles(a.p, a.h_in, a.h_out); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 50b2cea177..b230605cdc 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -5203,6 +5203,22 @@ fn pencil_extend_closes_as_closed_says() { } } +/// A continued path closed from away from its other end closes with a straight segment, even when +/// that end carries a handle the open path didn't use. +#[test] +fn pencil_extend_closes_far_away_with_a_straight_segment() { + let mut s = session(); + let anchors = json!([{"x": 0, "y": 0, "in": [-40, 30]}, {"x": 200, "y": 0}]); + let l = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap()); + let points = json!([[200, 0], [200, 100], [100, 150], [0, 50]]); + let p = json!({"points": points, "fidelity": 1.5, "closed": true, "extend": {"id": l.0}, "keepSelected": false}); + s.execute("path.freehand", &p).unwrap(); + let sp = path(&s, l).subpaths[0].clone(); + assert!(sp.closed); + let (first, last) = (sp.anchors[0], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((first.h_in, last.h_out), (first.p, last.p), "the closing segment has no handles: {:?}", sp.anchors); +} + /// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never /// crash, and what they draw has finite anchors. #[test] diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 67a740d4a3..d88b8250a2 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -723,10 +723,8 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} off"); } s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap(); - for (tool, c) in own { - // The Pencil's star is drawn on the crosshair and keeps its badge. - let want = if tool == "pencil" { c } else { Cursor::Crosshair }; - assert_eq!(cursor(&mut s, tool), want, "{tool} on"); + for (tool, _) in own { + assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on"); } for tool in always { assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on"); @@ -760,8 +758,8 @@ fn use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer() { } } -/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors. Use Precise Cursors keeps -/// them: they are drawn on the crosshair, and the ring says the release closes the path. +/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors +/// turns those into a crosshair too. #[test] fn alt_shows_the_pencil_and_paintbrush_closing_cursors() { use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; @@ -775,7 +773,7 @@ fn alt_shows_the_pencil_and_paintbrush_closing_cursors() { s.select_tool(tool, ViewInfo::default()).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 250.0), ViewInfo::default()).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 300.0, 300.0), ViewInfo::default()).unwrap(); - assert_eq!(s.cursor(at, alt, ViewInfo::default()), c, "{tool}, precise {precise}"); + assert_eq!(s.cursor(at, alt, ViewInfo::default()), if precise { Cursor::Crosshair } else { c }, "{tool}, precise {precise}"); s.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 300.0), ViewInfo::default()).unwrap(); } } diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 56e2cd2ae7..85b9cf258e 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -259,9 +259,7 @@ impl Cursor { /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools', the Live - /// Paint tools') become a plain crosshair at the hotspot. The Pencil's and the Paintbrush's - /// badged cursors are drawn on the crosshair already and keep their badge, which says what the - /// press or the release does (a new path, continuing, closing, a straight line). + /// Paint tools') become a plain crosshair at the hotspot. pub fn precise(self) -> Self { match self { Cursor::Pen @@ -284,7 +282,12 @@ impl Cursor { | Cursor::Lasso | Cursor::MagicWand | Cursor::Pencil + | Cursor::PencilNew + | Cursor::PencilClose + | Cursor::PencilStraight + | Cursor::PencilContinue | Cursor::Paintbrush + | Cursor::PaintbrushClose | Cursor::BlobBrush | Cursor::Eraser | Cursor::Smooth diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index db189adfc8..51756bf0fd 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -631,7 +631,6 @@ id = "PencilNew" group = "Drawing and editing tools" shown_when = [ "The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star.", - "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", ] hotspot = "The lead tip." look = "The Pencil cursor with a small star badge, where PencilClose has its ring." @@ -643,7 +642,6 @@ shown_when = [ "The Pencil tool with Alt pressed after the drag starts, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path. A drag started with Shift or Alt held draws an open straight line and does not show this cursor.", "The Pencil tool while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.", "The Pencil tool while a stroke continuing a selected open path, freehand or a straight line, is drawn to within Close Paths When Ends Are Within of that path's other end: releasing the mouse button there closes the path.", - "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", ] hotspot = "The lead tip." look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." @@ -653,7 +651,6 @@ id = "PencilStraight" group = "Drawing and editing tools" shown_when = [ "The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone.", - "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", ] hotspot = "The lead tip." look = "The Pencil cursor with a short level-line badge, where PencilClose has its ring." @@ -664,7 +661,6 @@ group = "Drawing and editing tools" shown_when = [ "The Pencil tool over an end of a selected open path, within Edit Selected Paths, before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press continues the path from that end, freehand, or with a straight line when Shift or Alt is held.", "The Pencil tool while a stroke continuing a selected path ends within Edit Selected Paths of an open end of another path selected at the press: releasing the mouse button connects the two.", - "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", ] hotspot = "The lead tip." look = "The Pencil cursor with a short badge line rising to the right, where PencilClose has its ring (the stroke carries on from the path)." @@ -681,7 +677,6 @@ group = "Drawing and editing tools" shown_when = [ "The Paintbrush tool with Alt held while drawing, or before a press when no path is selected that the Smooth tool can act on (with one selected, Smooth shows): releasing the mouse button with Alt still held closes the path.", "The Paintbrush tool while a stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.", - "With Use Precise Cursors on it shows as well: it is drawn on the crosshair, and the badge stays.", ] hotspot = "The bristle tip." look = "The Paintbrush cursor with a ring badge (closing the loop), like PenClose." diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 2159b8af2c..33d6520b84 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -756,7 +756,7 @@ fn pencil_shows_the_straight_line_cursor_when_a_press_draws_a_line() { t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "a freehand stroke"); assert_eq!(GestureTool::new("paintbrush").cursor(&cx, at, shift), Cursor::Paintbrush); - assert_eq!(Cursor::PencilStraight.precise(), Cursor::PencilStraight, "Use Precise Cursors keeps the badge"); + assert_eq!(Cursor::PencilStraight.precise(), Cursor::Crosshair, "Use Precise Cursors"); } /// Before a press, the Pencil shows its new-path cursor (the star) exactly when the press draws @@ -891,7 +891,7 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let cx_none = crate::testutil::cx(&d, &none, &p); assert_eq!(GestureTool::new("pencil").cursor(&cx_none, end, shift), Cursor::PencilStraight, "nothing selected"); assert_eq!(GestureTool::new("paintbrush").cursor(&cx, end, shift), Cursor::Paintbrush); - assert_eq!(Cursor::PencilContinue.precise(), Cursor::PencilContinue, "Use Precise Cursors keeps the badge"); + assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); } /// A Pencil stroke continuing a selected path shows the continuation cursor while a release would diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ee75655b26..15fec287b5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -673,9 +673,9 @@ mod cursor_tests { // Away from a selected path the Pencil shows its new-path cursor, drawn with a star; its // own cursor, for drawing and for a selected path under the pointer, borrows the crosshair. assert_eq!(cursor_at("pencil", &d, away), Cursor::PencilNew); - // Its badged cursors are drawn on the crosshair, and Use Precise Cursors keeps the badge. + // Its badged cursors too: Use Precise Cursors shows the plain crosshair. for c in [Cursor::PencilNew, Cursor::PencilClose, Cursor::PencilStraight, Cursor::PencilContinue, Cursor::PaintbrushClose] { - assert_eq!(c.precise(), c, "{c:?}"); + assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); } assert_eq!(Cursor::PencilNew.stand_in(), None); assert_eq!(Cursor::Pencil.precise(), Cursor::Crosshair); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 66e6c368ec..ebd46d944c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -59,7 +59,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| | 2026-11-14 | minor | Pencil and Paintbrush: a closed stroke (Alt at the release, or ending within Close Paths When Ends Are Within of its start) is fitted as the open stroke would be and closed by a straight segment back to its start, instead of curving on past both ends (`pencil_alt_closes_a_stroke_straight_back_to_its_start` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | -| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release; with Use Precise Cursors (Caps Lock) the Pencil's and the Paintbrush's badged cursors keep their badge (`a_continued_stroke_shows_where_it_connects_or_closes` in `crates/tools/src/draw2/gesture/tests.rs`, `pencil_extend_closes_as_closed_says` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | +| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release (`a_continued_stroke_shows_where_it_connects_or_closes` in `crates/tools/src/draw2/gesture/tests.rs`, `pencil_extend_closes_as_closed_says` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Snap to Point: with Smart Guides off it still pulls drawn and dragged points onto anchors and ruler guides, but draws none of the Smart Guides labels ("anchor") or lines (#7250; `guides::tests::snap_to_point_without_smart_guides_shows_no_guides`). Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | @@ -1090,7 +1090,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band. While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. Smooth Tool Options' Fidelity is in pixels, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=79) gives it: the Smooth tool, and an Alt smoothing drag with the Pencil, Paintbrush or Blob Brush, refits the path at Fidelity divided by the zoom, so a drag smooths a path as much on screen at 25 % as at 800 %. Reading the pixels as screen pixels is VectorCraft's own rule, as for the Pencil and the Paintbrush (#5729); the range (0.05 to 100 px) and the default (2.5 px) are VectorCraft's own values, and a saved Fidelity keeps its number (`crates/tools/src/draw2/gesture/tests.rs` test `smooth_fidelity_is_in_screen_pixels_at_any_zoom`, `crates/engine/src/tests/draw2.rs` test `smooth_region_refits_at_the_tolerance_sent_at_any_zoom`). - With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short level line), as the page describes. Over an end of a selected open path, where the stroke continues the path, freehand or as a straight line, it shows a path-continuation cursor (a crosshair with a short line rising to the right), as [Extend paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/extend-paths-with-the-pencil-tool.html) describes, and again while a continuing stroke ends near an open end of another selected path, where the release connects the two ([Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html)). A continuing stroke, a straight line too, that ends within Close Paths When Ends Are Within of the path's other end shows the closing cursor, and the release closes the path; Alt held at the release closes it too. + With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short level line), as the page describes. Over an end of a selected open path, where the stroke continues the path, freehand or as a straight line, it shows a path-continuation cursor (a crosshair with a short line rising to the right), as [Extend paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/extend-paths-with-the-pencil-tool.html) describes, and again while a continuing stroke ends near an open end of another selected path, where the release connects the two ([Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html)). A continuing stroke, a straight line too, that ends within Close Paths When Ends Are Within of the path's other end shows the closing cursor, and the release closes the path; Alt held at the release closes a continuing freehand stroke too (a straight line stays open, as above). - **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. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). 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). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. 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. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. 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). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. 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. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. 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). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. 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). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c812ee3db2..de00b9cc69 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -52,7 +52,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| | 2026-11-14 | minor | Layers panel: right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. Scores unchanged. | -| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release; with Use Precise Cursors (Caps Lock) the Pencil's and the Paintbrush's badged cursors keep their badge. Scores and estimates unchanged. | +| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release. Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. | @@ -342,7 +342,7 @@ tools, never checked side by side) isn't captured row by row. | Polygon/Star exact-size placement remembers the chosen sides/points for later drags and dialog openings; Cancel preserves the previous count | done | Session tool options; existing dialog-opening gestures retained; regression in `toolbar.rs` | | Tool double-click opens its options | done | | | Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | | -| Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change; the Pencil's and the Paintbrush's cursors with a badge (new path, continue, close, straight line) are drawn on the crosshair and keep their badge (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | +| Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | | macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. While it owns the cursor, the host sets it again on every pass, so the tool's cursor stays when the pointer moves (#6362). Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | | Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | From 00767dcf57b1748f4c5a24e4ea1a7c4ea0f9ded9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:52:17 -0800 Subject: [PATCH 49/89] Color: picked gradient mesh points are the color the panels show and edit With points of a gradient mesh picked (Mesh tool, Direct Selection or the Lasso), a solid fill color from the Color panel recolors just those points, but the Fill proxy, the Color panel, the Color Wheel and the Control bar's fill chip kept showing the mesh object's own fill, so the panel's sliders jumped back after each drag. - The new `Session::fill_mesh_points` resolves the targets as `paint.setFill` does: the picked points of every selected mesh, unless the Appearance panel's active row is a fill or an object of a symbol instance is selected. When the first selected object is a mesh among them, the Fill proxy (`paint.proxies`) shows its first picked point's color, and "?" compares the picked points' colors with each other and with the other selected objects' fills. - `Session::mesh_points_picked` lists the points `transparency.set` fades: it is empty while the Appearance panel's active row takes the opacity, and `transparency.info` and the Transparency panel now follow it there too. - The cached "?" state and the Transparency panel's values are recomputed when other points are picked, which gives the document no new revision. - The Control bar's opacity shows the first picked point's opacity, which `transparency.set` changes then. - The `paint.setFill` and `transparency.set` params texts name the picked points instead of the point last clicked, and the `paint.proxies` text, docs/mcp.md and the progress doc describe what the panels show. --- crates/engine/src/cmd/opacitymask.rs | 5 +- crates/engine/src/cmd/paint.rs | 84 +++++++++++++++++++--- crates/engine/src/tests/xform.rs | 86 +++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 30 +++++--- crates/ui-egui/src/panels/color/tests.rs | 54 ++++++++++++++ crates/ui-egui/src/panels/transparency.rs | 43 +++++++++++- docs/mcp.md | 7 +- docs/target-app-parity.md | 1 + 8 files changed, 285 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 75d5852ea2..ac3c780c11 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -472,7 +472,7 @@ impl Session { pub fn transparency_info(&self) -> TransparencyInfo { match (self.active(), transparency_targets(self, &Value::Null)) { (Some(st), Ok(ids)) => { - let points = super::paint::picked_mesh_points_of(self, &ids); + let points = self.mesh_points_picked(); TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points) } _ => TransparencyInfo::default(), @@ -483,7 +483,8 @@ impl Session { fn info(s: &mut Session, p: &Value) -> Result<Value> { let st = s.doc()?; let ids = transparency_targets(s, p)?; - let points = super::paint::picked_mesh_points_of(s, &ids); + // The points `transparency.set` with the same `ids` (or none) fades. + let points = if p.get("ids").is_none() && p.get("id").is_none() { s.mesh_points_picked() } else { super::paint::picked_mesh_points_of(s, &ids) }; let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points); Ok(json!({ "ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(), diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 3ec28e7ce7..33ef3ad5fc 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -154,7 +154,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> { "Fill and Stroke", [], None, - "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}", + "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}", always, proxies ), @@ -179,9 +179,13 @@ impl Session { /// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the /// first selected object's own stack) for the proxy of its kind, else the first selected /// object's (a group's first painted object's; type: its first run's), else the defaults for - /// new art. + /// new art. When the object whose fill the proxy shows is a gradient mesh with points picked + /// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid + /// fill color changes. pub fn proxy_paints(&self) -> (Paint, Paint) { + let points = self.fill_mesh_points(); let show = |stroke: bool| match self.proxy_source(stroke) { + Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c), Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), @@ -273,6 +277,13 @@ impl DocState { /// each selected object's own stack is compared. One walk of the document, so callers that ask /// every frame cache it by revision. pub fn proxy_mixed(&self, item: Option<usize>) -> (bool, bool) { + self.proxy_mixed_with(item, &[]) + } + + /// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors + /// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of + /// its picked points instead of its fill. + fn proxy_mixed_with(&self, item: Option<usize>, points: &[(NodeId, Vec<usize>)]) -> (bool, bool) { if self.selection.is_empty() { return (false, false); } @@ -291,7 +302,10 @@ impl DocState { let differ = |stroke: bool| { let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke)); let set = if item.is_some() { &selected } else { &nodes }; - let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item)); + let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) { + c if c.is_empty() => vec![proxy_paint(n, stroke, item)], + c => c.into_iter().map(Paint::solid).collect(), + }); let Some(p0) = paints.next() else { return false }; paints.any(|p| !same_in_proxy(&p0, &p)) }; @@ -313,8 +327,9 @@ impl DocState { impl Session { /// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item. + /// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors. pub fn proxy_mixed(&self) -> (bool, bool) { - self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item())) + self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points())) } } @@ -539,8 +554,8 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; - // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill - // recolours just those points of their meshes. + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid + // fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`). let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; // Without `item`, unless the Appearance panel's active fill item takes the paint. let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top { @@ -871,6 +886,55 @@ pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option<Vec<( (!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v) } +/// The colors of the picked points of `n` (see [`picked_mesh_points`]) when it is a gradient mesh +/// among `points`, in the order they were picked; empty otherwise. +fn picked_colors(n: &Node, points: &[(NodeId, Vec<usize>)]) -> Vec<Color> { + match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) { + (NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(), + _ => vec![], + } +} + +impl Session { + /// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or + /// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are + /// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's + /// active item is a fill row or an object of a symbol instance is selected, and a mesh with + /// points picked counts even when other objects are selected with it. + pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec<usize>)> { + let Some(st) = self.active() else { return vec![] }; + // Only gradient meshes have points to pick: a selection with no mesh and no container that + // could hold one skips reading the tool's options. + let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container()); + if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) { + return vec![]; + } + let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] }; + if item != ItemTarget::Top { + return vec![]; + } + let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] }; + picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected)) + } + + /// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set` + /// without `ids` or `item`) fades instead of their objects: `None` unless every target is a + /// gradient mesh with points picked, and while the Appearance panel's active item takes the + /// opacity (outside opacity mask editing). + pub fn mesh_points_picked(&self) -> Option<Vec<(NodeId, Vec<usize>)>> { + let st = self.active()?; + let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?; + // Only gradient meshes have points to pick: other targets skip reading the tool's options. + if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) { + return None; + } + if st.doc.mask_edit.is_none() && self.appearance_item().is_some() { + return None; + } + picked_mesh_points_of(self, &ids) + } +} + /// A type run's stroke; a painted stroke on a run without one gets 1 pt. pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) { if !paint.is_none() { @@ -1084,8 +1148,8 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>())); - // The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity - // themselves, so only the mesh around them fades (#4206, #4170, #4208). + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the + // opacity themselves, so only the mesh around them fades (#4206, #4170, #4208). if matches!(item, ItemTarget::Top) && let Some(o) = q.get("opacity").and_then(Value::as_f64) && let Some(points) = picked_mesh_points_of(s, &ids) diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index f507b765c6..1a41488285 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -1115,6 +1115,92 @@ fn lasso_picks_gradient_mesh_points() { assert_eq!(red, top, "only the points inside the loop are recoloured"); } +/// With gradient mesh points picked, the Fill proxy (and the Color panel that edits it) shows +/// the color a solid fill gives them, not the mesh object's own fill: "?" when the picked +/// points' colors differ, the object's fill again once no point is picked. +#[test] +fn the_fill_proxy_shows_the_picked_mesh_points_color() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let object_fill = s.proxy_paints().0; + // Round the top row of points. + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let shown = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()), "the picked points' color: {shown}"); + assert_eq!(shown["fillMixed"], json!(false)); + assert_eq!(shown["fill"], json!(s.proxy_paints().0)); + // One picked point takes another color: the points differ. + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + let first = m.idx(0, 0); + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": first, "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points' colors differ"); + // Picking none (the Selection tool) shows the object's fill again. + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.proxy_paints().0, object_fill); + assert!(!s.proxy_mixed().0); +} + +/// With the Appearance panel's stroke row active, a solid fill still recolors the picked mesh +/// points, so the Fill proxy shows them; with its fill row active, the fill goes to that row, and +/// the proxy shows the row. +#[test] +fn the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + let items = &s.doc().unwrap().doc.node(a).unwrap().appearance.items; + let fill_row = items.iter().position(|it| it.is_fill()).expect("a fill row"); + let stroke_row = items.iter().position(|it| !it.is_fill()).expect("a stroke row"); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + assert_eq!(s.fill_mesh_points().first().map(|(_, v)| v.len()), Some(3), "the top row is picked"); + let point_color = |s: &Session| { + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + m.points[m.idx(0, 0)].color.to_hex() + }; + + s.execute("appearance.setActiveItem", &json!({"index": stroke_row})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the stroke row leaves the fill on the picked points"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0); + + s.execute("appearance.setActiveItem", &json!({"index": fill_row})).unwrap(); + assert!(s.fill_mesh_points().is_empty()); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the fill row takes the fill"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#00ff00".into())); +} + +/// A gradient mesh with points picked selected with another object: a solid fill recolors the +/// points and fills the other object, and the Fill proxy and its "?" count the points' colors. +#[test] +fn the_fill_proxy_counts_picked_mesh_points_beside_other_objects() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + assert_eq!(s.fill_mesh_points().first().map(|(id, v)| (*id, v.len())), Some((a, 3)), "the top row is still picked"); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#3366ff"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0, "the points and the other object are the same color"); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points and the other object's fill differ"); +} + /// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are /// deselected and the others selected, the rest of the selection staying as it was. #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 71019385b6..8923c6f0ce 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -54,7 +54,7 @@ pub mod variables; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{LiveCorners, Node, StrokeLayer}; +use vectorcraft_doc::{LiveCorners, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_engine::inspect::StrokeMixed; use vectorcraft_geom::shapes::CornerKind; @@ -322,11 +322,13 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { } /// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per -/// document revision and Appearance panel item. +/// document revision, Appearance panel item and picked gradient mesh points (picking points +/// changes no revision). pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) { + type Key = (u64, u64, Option<usize>, Vec<(NodeId, Vec<usize>)>); let Some(st) = app.session.active() else { return (false, false) }; - let key = (st.uid, st.revision, app.session.appearance_item()); - match pstate::<Option<((u64, u64, Option<usize>), (bool, bool))>>(ctx, "proxy-mixed") { + let key: Key = (st.uid, st.revision, app.session.appearance_item(), app.session.fill_mesh_points()); + match pstate::<Option<(Key, (bool, bool))>>(ctx, "proxy-mixed") { Some((k, mixed)) if k == key => mixed, _ => { let mixed = app.session.proxy_mixed(); @@ -365,13 +367,23 @@ pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeM } /// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance -/// panel's active item's, else the first selected object's. +/// panel's active item's, else the first picked gradient mesh point's opacity (which +/// `transparency.set` changes then) with its object's blend mode, else the first selected object's. pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> { let n = first_node(app)?; - Some(match app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { - Some(it) => (it.opacity(), it.blend()), - None => (n.opacity, n.blend), - }) + if let Some(it) = app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { + return Some((it.opacity(), it.blend())); + } + let picked = app.session.mesh_points_picked().and_then(|points| { + let (id, v) = points.first()?; + let d = &app.session.active()?.doc; + let m = match &d.node(*id)?.kind { + NodeKind::Mesh(m) => m, + _ => return None, + }; + Some((m.points.get(*v.first()?)?.opacity, d.node(*id)?.blend)) + }); + Some(picked.unwrap_or((n.opacity, n.blend))) } /// Is the active proxy "?" (the selected objects' paints differ)? diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index cd7b88cdd0..bd605eef01 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -949,6 +949,60 @@ fn a_mixed_selection_has_no_colour_to_edit() { assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false), "a new revision recomputes it"); } +/// With gradient mesh points picked, the panel edits and shows their color (a solid fill +/// recolors just them), not the mesh object's fill, so the sliders keep the value they set, +/// through a drag too; "?" when the picked points differ, which picking alone (no new revision) +/// updates. The Control bar's opacity is the picked point's. +#[test] +fn picked_mesh_points_are_the_color_the_panel_shows_and_edits() { + use vectorcraft_doc::NodeKind; + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().selection.objects[0]; + app.run("paint.setFill", json!({"color": "#ff8500"})).unwrap(); + app.run("object.mesh.create", json!({"rows": 2, "cols": 2})).unwrap(); + app.run("object.mesh.setPointColor", json!({"id": id.0, "index": 0, "color": "#0000ff", "opacity": 0.5})).unwrap(); + let v = ViewInfo::default(); + app.session.select_tool("mesh", v).unwrap(); + let click = |app: &mut VectorcraftApp, x: f64, y: f64, shift: bool| { + for k in [PointerKind::Down, PointerKind::Up] { + let mut ev = PointerEvent::new(k, x, y); + ev.mods.shift = shift; + app.session.pointer(&ev, v).unwrap(); + } + }; + let picked = |app: &VectorcraftApp| app.session.fill_mesh_points().into_iter().flat_map(|(_, v)| v).collect::<Vec<_>>(); + // The top-left corner point (index 0). + click(&mut app, 10.0, 10.0, false); + assert_eq!(picked(&app), vec![0], "the click picks the top-left point"); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false)); + let tgt = target_with(&app, false); + assert_eq!(tgt.color().map(|c| c.to_hex()), Some("#0000ff".into()), "the picked point's color, not the object's fill"); + assert_eq!(crate::panels::current_transparency(&app).map(|t| t.0), Some(0.5), "the picked point's opacity"); + // A slider drag: each step shows the color it set, and the release keeps it. + for c in [Color::rgb8(250, 0, 120), Color::rgb8(240, 10, 140)] { + let tgt = target_with(&app, false); + apply(&mut app, &tgt, c, Live::Dragging, false); + assert_eq!(target_with(&app, false).color(), Some(c), "the drag shows the color it set"); + } + let orange = Color::rgb8(255, 128, 0); + let tgt = target_with(&app, false); + apply(&mut app, &tgt, orange, Live::Released, false); + assert_eq!(target_with(&app, false).color(), Some(orange), "the panel keeps the color it set"); + let NodeKind::Mesh(m) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("still a gradient mesh") }; + assert_eq!(m.points[0].color, orange); + let middle = m.idx(0, 1); + assert_ne!(m.points[middle].color, orange, "only the picked point"); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false)); + // Shift-click the top middle point (still the object's color): the two differ. + click(&mut app, 35.0, 10.0, true); + assert_eq!(picked(&app), vec![0, middle], "the Shift-click adds the top middle point"); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (true, false), "picking recomputes it"); +} + #[test] fn rgb_roundtrip() { let c = Color::rgb8(230, 120, 40); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 229a23c411..4f23266e97 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -20,12 +20,12 @@ pub(super) const MASK_THUMB: &str = "tr-mask-thumb"; /// The panel's values ([`vectorcraft_engine::Session::transparency_info`]) and its first target /// (the selection, or the object whose mask is being edited), recomputed only when the document -/// or the selection changes. +/// or the selection changes, or other gradient mesh points are picked. fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (TransparencyInfo, Option<Node>) { - type Key = (u64, u64, usize, Option<NodeId>, Option<NodeId>); + type Key = (u64, u64, usize, Option<NodeId>, Option<NodeId>, Option<Vec<(NodeId, Vec<usize>)>>); let Some(st) = app.session.active() else { return Default::default() }; let sel = &st.selection.objects; - let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied()); + let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied(), app.session.mesh_points_picked()); let id = egui::Id::unique("tr-state"); if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (TransparencyInfo, Option<Node>))>(id)) && k == key @@ -351,6 +351,43 @@ mod tests { assert!(app.session.active().unwrap().doc.mask_edit.is_none()); } + /// Picking other gradient mesh points (which gives the document no new revision) shows their + /// opacity, the one `transparency.set` changes then. + #[test] + fn picking_mesh_points_shows_their_opacity() { + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].clone(); + run(&mut app, "object.mesh.create", json!({"rows": 2, "cols": 2})); + run(&mut app, "object.mesh.setPointColor", json!({"id": a, "index": 0, "opacity": 0.3})); + run(&mut app, "paint.setStroke", json!({"color": "#000000"})); + let v = ViewInfo::default(); + app.session.select_tool("mesh", v).unwrap(); + let click = |app: &mut VectorcraftApp, x: f64, y: f64| { + for k in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + }; + let picked = |app: &VectorcraftApp| app.session.mesh_points_picked().map(|p| p.into_iter().flat_map(|(_, v)| v).collect::<Vec<_>>()); + let ctx = egui::Context::default(); + click(&mut app, 35.0, 10.0); + assert_ne!(picked(&app), Some(vec![0]), "the click picks the top middle point"); + assert_eq!(picked(&app).map(|v| v.len()), Some(1)); + assert_eq!(state(&app, &ctx).0.opacity, Some(100.0)); + click(&mut app, 10.0, 10.0); + assert_eq!(picked(&app), Some(vec![0]), "the click picks the top-left point"); + assert_eq!(state(&app, &ctx).0.opacity, Some(30.0), "the newly picked point's opacity"); + // An active Appearance row takes the opacity, so the values are the object's again. + let rows = app.session.active().unwrap().selection.objects.first().map(|id| app.session.active().unwrap().doc.node(*id).unwrap().appearance.items.len()); + assert!(rows.is_some_and(|n| n > 0), "the mesh has its stroke row"); + run(&mut app, "appearance.setActiveItem", json!({"index": 0})); + assert_eq!(picked(&app), None); + assert_eq!(app.session.transparency_info().opacity, Some(100.0), "the object's opacity, not the point's"); + assert_eq!(run(&mut app, "transparency.info", json!({}))["opacity"], json!(100.0)); + } + #[test] fn mixed_values_show_blank() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..fec3a36e96 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10573,7 +10573,12 @@ and the arrow keys nudge them by the keyboard increment. The active tool lists t (`tool.setOption {}` returns them as `meshPoints: [{id, index}…]`). On gradient meshes a solid fill colour (`paint.setFill`, from the Color panel, Swatches or the Eyedropper) recolours just the picked points, and when every target of `transparency.set` is a gradient mesh with points picked, its `opacity` fades those points instead of the objects -(`transparency.info` then shows their opacity, `null` when it differs). Both are one undo step. +(`transparency.info` then shows their opacity, `null` when it differs). Both are one undo step, and neither goes to the +points while the Appearance panel's active row takes the fill or the opacity. When the first selected object is a +gradient mesh with points picked, the Fill proxy, the Color panel, the Color Wheel and `paint.proxies` show its first +picked point's color, and `fillMixed` compares the picked points' colors with each other and with the fills of the other +selected objects. The Transparency panel shows the picked points' opacity (blank when it differs), and the Control +bar shows the first picked point's opacity. `object.mesh.movePoints {id, points: [index…], dx, dy}` (or `{items: [{id, points}…], dx, dy}` over several meshes) moves points by (dx, dy), their handles with them; `object.mesh.setPointColor` takes `points` or `items` the same diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..f41e1a4631 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1445,6 +1445,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradient mesh keeps the object's outline (#2752):** Create Gradient Mesh, a Mesh tool click and Expand of a gradient to a mesh lay the mesh's border on the path's outline: each border edge is the piece of the outline between its two points, and where the outline bends through an anchor (a star's points, an L-shape's inner corner, a rounded rectangle's arcs) no single curve follows, the mesh takes a line through that anchor, up to 256 each way. Holes and other subpaths of a compound path are still left out. Public reference: [About mesh objects and Create mesh objects, CS3 user guide, pages 184 to 185](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) (mesh lines carry anchor points as well as mesh points). Tests: `a_mesh_keeps_the_outline_of_every_shape` and `a_mesh_takes_lines_only_where_the_outline_needs_them` in `crates/doc/src/live.rs`, `a_mesh_tool_click_keeps_a_stars_points` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **A point on a mesh's outline is on the mesh:** a point exactly on the outline of a gradient mesh (within a millionth of a point) locates on the edge it lies on, on all four sides alike and with no tolerance, as it already did on the top and left edges, so `object.mesh.addLine` and Create Gradient Mesh `at` a point on the bottom or right edge add their lines there instead of reporting the point outside, and the Eyedropper samples the mesh's color there; a point just outside still needs `tolerance`. Tests: `mesh_locate_takes_a_point_on_any_side_of_the_outline` in `crates/doc/src/live.rs`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. +- **Picked mesh points in the panels** (VectorCraft's own rule): with points of a gradient mesh picked by the Mesh tool, Direct Selection or the Lasso, the Fill proxy, the Color panel and the Color Wheel show the first picked point's color, so their sliders keep the color a drag sets, and "?" shows when the picked points' colors differ. The Control bar shows the first picked point's opacity. Tests: `the_fill_proxy_shows_the_picked_mesh_points_color`, `the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active` and `the_fill_proxy_counts_picked_mesh_points_beside_other_objects` in `crates/engine/src/tests/xform.rs`, `picked_mesh_points_are_the_color_the_panel_shows_and_edits` in `crates/ui-egui/src/panels/color/tests.rs`, `picking_mesh_points_shows_their_opacity` in `crates/ui-egui/src/panels/transparency.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo flat-art check with named colors:** with `logoColors` named, whether the image is flat art is judged by its own colors, as many as are named, with white, or as the Colors setting gives, whichever is more. Named colors 20 levels off a flat badge's colors still trace it, a hatch on a light ground gives the same result with close and far named colors, a gradient's refusal score does not depend on how far the named colors are from it, and areas of colors that were not named are traced in the named colors instead of being refused; code-drawn tests in `crates/trace`. From 786dcc55810259e036ad8489b83fa11e6ec5cd35 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 04:24:56 -0800 Subject: [PATCH 50/89] Images: scaled-down images keep their color beside clear pixels The image crate's filtered resize filters each channel on its own, which is correct only for premultiplied pixels, and its thumbnail averages each channel on its own too. Placed images, linked-image previews, MCP captures and previews, metafile bitmaps, PDF downsampling and library thumbnails passed straight RGBA to them, so the color under clear pixels (usually black) bled into the opaque pixels beside them and darkened cut-out edges. vectorcraft_doc::resample scales such images with each pixel's color weighted by its alpha: filtered resampling premultiplies the pixels in place and divides them back after, and averaging takes the area-weighted mean of the pixels each new one covers. Opaque and alpha-free images are scaled as before. PDF export also multiplies the inks of a CMYK image with alpha by that alpha before resampling them, and divides them by the resampled alpha after. --- crates/doc/src/lib.rs | 1 + crates/doc/src/links.rs | 18 +- crates/doc/src/resample.rs | 259 +++++++++++++++++++ crates/mcp/src/tests.rs | 37 +++ crates/mcp/src/tools/dispatch.rs | 3 +- crates/mcp/src/tools/handlers.rs | 3 +- crates/metafile/src/dib.rs | 19 +- crates/pdf/src/images.rs | 95 +++++-- crates/pdf/src/tests/compression.rs | 32 +++ crates/render/src/paint.rs | 36 ++- crates/ui-egui/src/panels/libraries.rs | 11 +- crates/ui-egui/src/panels/libraries/tests.rs | 13 + 12 files changed, 502 insertions(+), 25 deletions(-) create mode 100644 crates/doc/src/resample.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c51e099840..55b89049b7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -61,6 +61,7 @@ pub mod range; pub mod rastersettings; mod reach; pub mod recolor; +pub mod resample; pub mod selection; pub mod separated; pub mod setup; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index ffa9eac0cd..b17170dd70 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -229,7 +229,8 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> { /// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG. fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> { - let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE); + let (w, h) = crate::resample::fit_size(img.width(), img.height(), PROXY_SIZE, PROXY_SIZE); + let small = crate::resample::averaged(img, w, h); let mut png = Vec::new(); small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; Some(png) @@ -728,4 +729,19 @@ mod tests { image(&mut d, "c", None); assert_eq!(d.linked_only_images().into_iter().collect::<Vec<_>>(), ["a"]); } + + /// The preview of an image with clear pixels keeps the color of its opaque ones: the clear + /// black beside them does not darken them, whichever way the preview is made. + #[test] + fn a_preview_keeps_the_color_beside_clear_pixels() { + let mut full = vec![]; + image::RgbaImage::from_fn(1024, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut Cursor::new(&mut full), image::ImageFormat::Png) + .unwrap(); + for blob in [ImageBlob::png(full.clone()).with_proxy(), ImageBlob::png(full.clone()).checked_with_proxy(true).unwrap()] { + let p = image::load_from_memory(blob.proxy.as_ref().unwrap()).unwrap().into_rgba8(); + assert_eq!(p.dimensions(), (PROXY_SIZE, 1)); + assert!(p.pixels().all(|q| q.0 == [200, 30, 30, 128]), "{:?}", p.get_pixel(0, 0)); + } + } } diff --git a/crates/doc/src/resample.rs b/crates/doc/src/resample.rs new file mode 100644 index 0000000000..bc72cce1db --- /dev/null +++ b/crates/doc/src/resample.rs @@ -0,0 +1,259 @@ +//! Scaling images whose pixels carry straight (not premultiplied) alpha. +//! +//! `image`'s filtered resampling filters each channel on its own, which is correct only for +//! premultiplied pixels, and its thumbnail averages each channel on its own too, so on +//! straight-alpha pixels the color under clear pixels bleeds into the opaque ones next to them +//! (clear black darkens a cut-out's edge). [`resized`], [`fitted`] and [`averaged`] weight each +//! pixel's color by its alpha while scaling, as the streaming shrink in the renderer does. + +use image::imageops::FilterType; +use image::{DynamicImage, RgbaImage}; + +/// How [`resized`] makes each new pixel from the old ones. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Resample { + /// `image`'s filtered resampling with this filter. + Filter(FilterType), + /// The mean of the old pixels each new one covers ([`averaged`]). + Average, +} + +/// `img` scaled to `w` × `h` pixels as `how` says, each pixel's color weighted by its alpha. +/// Filtered or averaged, an image with clear or partly clear pixels comes back as 8-bit RGBA +/// whatever its own pixel format, and a pixel that ends up wholly clear is transparent black. An +/// image without clear pixels is scaled as `image` scales it, in its own pixel format. +/// Nearest-neighbor picking leaves the pixels as they are, in their own format, and is not +/// weighted. Filtered, the colors keep the precision of premultiplied 8-bit pixels, and an 8-bit +/// RGBA image is premultiplied in place. +pub fn resized(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + if (w, h) == (img.width(), img.height()) { + return img; + } + let f = match how { + Resample::Average => return averaged(&img, w, h), + Resample::Filter(f) => f, + }; + if f == FilterType::Nearest || opaque(&img) { + return img.resize_exact(w, h, f); + } + let mut rgba = img.into_rgba8(); + for p in rgba.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = times_alpha(*c, *a); + } + } + let mut out = image::imageops::resize(&rgba, w, h, f); + for p in out.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = over_alpha(*c, *a); + } + } + DynamicImage::ImageRgba8(out) +} + +/// [`resized`] to the largest size that fits `w` × `h` pixels and keeps `img`'s proportions, the +/// size `image`'s own `resize` and `thumbnail` pick. +pub fn fitted(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + let (nw, nh) = fit_size(img.width(), img.height(), w, h); + resized(img, nw, nh, how) +} + +/// `img` scaled to `w` × `h` pixels, each new pixel the mean of the old pixels it covers, each old +/// pixel counted by the area of it covered and its color weighted by its alpha. `img` is read one +/// row at a time, so besides the result this takes one row of 8-bit RGBA at most. An image +/// without clear pixels is scaled as `image`'s thumbnail scales it. +pub fn averaged(img: &DynamicImage, w: u32, h: u32) -> DynamicImage { + let (sw, sh) = (img.width(), img.height()); + if opaque(img) || w == 0 || h == 0 || sw == 0 || sh == 0 { + return img.thumbnail_exact(w, h); + } + // Per new pixel of the row being made: red, green and blue times alpha, alpha (all times the + // area covered), and the area covered. + let mut sums = vec![([0f64; 4], 0f64); w as usize]; + let mut out = Vec::with_capacity(w as usize * h as usize * 4); + for ty in 0..h { + for (y, wy) in covered(ty, sh, h) { + let row = rgba_row(img, y as u32); + for ((s, area), tx) in sums.iter_mut().zip(0..w) { + for (x, wx) in covered(tx, sw, w) { + let Some(p) = row.get(x * 4..x * 4 + 4) else { continue }; + let k = wx * wy; + let a = f64::from(p[3]) * k; + s[0] += f64::from(p[0]) * a; + s[1] += f64::from(p[1]) * a; + s[2] += f64::from(p[2]) * a; + s[3] += a; + *area += k; + } + } + } + let byte = |v: f64| v.round().clamp(0.0, 255.0) as u8; + for (s, area) in &mut sums { + out.extend(if s[3] <= 0.0 { [0; 4] } else { [byte(s[0] / s[3]), byte(s[1] / s[3]), byte(s[2] / s[3]), byte(s[3] / *area)] }); + (*s, *area) = ([0.0; 4], 0.0); + } + } + RgbaImage::from_raw(w, h, out).map_or_else(|| img.thumbnail_exact(w, h), DynamicImage::ImageRgba8) +} + +/// Row `y` of `img` as 8-bit RGBA: borrowed from an 8-bit RGBA image, converted from others. +fn rgba_row(img: &DynamicImage, y: u32) -> std::borrow::Cow<'_, [u8]> { + let n = img.width() as usize * 4; + match img { + DynamicImage::ImageRgba8(b) => std::borrow::Cow::Borrowed(b.as_raw().get(y as usize * n..(y as usize + 1) * n).unwrap_or_default()), + other => std::borrow::Cow::Owned(other.crop_imm(0, y, other.width(), 1).into_rgba8().into_raw()), + } +} + +/// The old pixels that new pixel `t` of `tn` covers along a side of `n` old pixels, each with the +/// length of it covered (in old pixels), worked out as they are read so that nothing is stored. +fn covered(t: u32, n: u32, tn: u32) -> impl Iterator<Item = (usize, f64)> { + let scale = f64::from(n) / f64::from(tn); + let (a, b) = (f64::from(t) * scale, (f64::from(t) + 1.0) * scale); + let (first, end) = (a.floor() as u32, (b.ceil() as u32).min(n)); + (first..end.max(first + 1).min(n)).filter_map(move |i| { + let len = b.min(f64::from(i) + 1.0) - a.max(f64::from(i)); + (len > 0.0).then_some((i as usize, len)) + }) +} + +/// Each 4-channel pixel of `px` with all four channels multiplied by its value in `alpha` (one +/// sample a pixel, 0 to 255): ink amounts or colors made ready for scaling beside a separate alpha +/// channel. Pixels past the end of `alpha` are left as they are. +pub fn premultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = times_alpha(*c, a); + } + } +} + +/// [`premultiply`] undone after scaling: each 4-channel pixel of `px` divided by its value in the +/// scaled `alpha`, and 0 where that is 0. +pub fn unpremultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = over_alpha(*c, a); + } + } +} + +/// Does `img` have no alpha channel, or is every pixel of it opaque? +fn opaque(img: &DynamicImage) -> bool { + match img { + DynamicImage::ImageRgba8(b) => b.pixels().all(|p| p[3] == u8::MAX), + DynamicImage::ImageLumaA8(b) => b.pixels().all(|p| p[1] == u8::MAX), + DynamicImage::ImageRgba16(b) => b.pixels().all(|p| p[3] == u16::MAX), + DynamicImage::ImageLumaA16(b) => b.pixels().all(|p| p[1] == u16::MAX), + DynamicImage::ImageRgba32F(b) => b.pixels().all(|p| p[3] >= 1.0), + other => !other.color().has_alpha(), + } +} + +/// Channel `c` times alpha `a`, both 0 to 255. +fn times_alpha(c: u8, a: u8) -> u8 { + ((u16::from(c) * u16::from(a) + 127) / 255) as u8 +} + +/// Premultiplied channel `c` divided by alpha `a` (0 when `a` is 0). +fn over_alpha(c: u8, a: u8) -> u8 { + if a == 0 { + return 0; + } + let a = u32::from(a); + ((u32::from(c) * 255 + a / 2) / a).min(255) as u8 +} + +/// The largest size of at most `nw` × `nh` with the proportions of `w` × `h`, at least a pixel a +/// side. +pub fn fit_size(w: u32, h: u32, nw: u32, nh: u32) -> (u32, u32) { + if w == 0 || h == 0 { + return (nw.max(1), nh.max(1)); + } + let ratio = (f64::from(nw) / f64::from(w)).min(f64::from(nh) / f64::from(h)); + let side = |n: u32| ((f64::from(n) * ratio).round() as u32).max(1); + (side(w), side(h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 4 × 2 image whose left half is opaque red and whose right half is clear black. + fn half_clear() -> DynamicImage { + DynamicImage::ImageRgba8(RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })) + } + + /// Is the color of `p`, premultiplied by its alpha, within a step of `rgb` premultiplied by the + /// same alpha (the precision of premultiplied 8-bit pixels)? + fn close(p: [u8; 4], rgb: [u8; 3]) -> bool { + p[..3].iter().zip(rgb).all(|(&c, want)| times_alpha(c, p[3]).abs_diff(times_alpha(want, p[3])) <= 1) + } + + #[test] + fn averaging_keeps_the_color_of_the_opaque_pixels() { + let p = resized(half_clear(), 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0; + assert!(close(p, [200, 30, 30]) && p[3].abs_diff(128) <= 1, "{p:?}"); + } + + /// Three pixels averaged into two: each new pixel takes one old pixel whole and half of the + /// clear one in the middle. + #[test] + fn averaging_counts_the_part_of_a_pixel_covered() { + let img = RgbaImage::from_fn(3, 1, |x, _| if x == 1 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([200, 30, 30, 255]) }); + let out = averaged(&DynamicImage::ImageRgba8(img), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 30, 30, 170], [200, 30, 30, 170]].concat()); + // Gray with alpha is read the same way. + let gray = image::GrayAlphaImage::from_fn(3, 1, |x, _| image::LumaA(if x == 1 { [0, 0] } else { [200, 255] })); + let out = averaged(&DynamicImage::ImageLumaA8(gray), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 200, 200, 170], [200, 200, 200, 170]].concat()); + } + + #[test] + fn filtering_keeps_the_color_of_the_opaque_pixels() { + for f in [FilterType::Triangle, FilterType::CatmullRom, FilterType::Lanczos3] { + let out = resized(half_clear(), 2, 1, Resample::Filter(f)).into_rgba8(); + for x in 0..2 { + let p = out.get_pixel(x, 0).0; + assert!(p[3] == 0 || close(p, [200, 30, 30]), "{f:?} pixel {x}: {p:?}"); + } + } + } + + #[test] + fn fitted_keeps_proportions() { + let img = DynamicImage::ImageRgba8(RgbaImage::new(400, 100)); + let out = fitted(img, 64, 64, Resample::Average); + assert_eq!((out.width(), out.height()), (64, 16)); + } + + #[test] + fn opaque_and_alpha_free_images_scale_as_image_scales_them() { + let rgb = DynamicImage::ImageRgb8(image::RgbImage::from_fn(5, 3, |x, y| image::Rgb([x as u8 * 40, y as u8 * 60, 7]))); + let ours = resized(rgb.clone(), 2, 2, Resample::Filter(FilterType::Triangle)); + assert_eq!(ours, rgb.resize_exact(2, 2, FilterType::Triangle)); + let opaque = DynamicImage::ImageRgba8(rgb.to_rgba8()); + assert_eq!(resized(opaque.clone(), 2, 2, Resample::Average), opaque.thumbnail_exact(2, 2)); + } + + #[test] + fn a_wholly_clear_result_is_transparent_black() { + let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 4, image::Rgba([90, 90, 90, 0]))); + assert_eq!(resized(img, 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0, [0, 0, 0, 0]); + } + + #[test] + fn premultiply_and_unpremultiply_scale_beside_a_separate_alpha() { + // Two pixels of inks, the second clear: averaged, the first one's inks remain. + let mut px = vec![40, 80, 120, 160, 0, 0, 0, 0]; + premultiply(&mut px, &[255, 0]); + let img = RgbaImage::from_raw(2, 1, px).unwrap(); + let mut out = image::imageops::thumbnail(&img, 1, 1).into_raw(); + let alpha = image::imageops::thumbnail(&image::GrayImage::from_raw(2, 1, vec![255, 0]).unwrap(), 1, 1).into_raw(); + unpremultiply(&mut out, &alpha); + let want = [40, 80, 120, 160]; + assert!(out.iter().zip(want).all(|(&c, w)| times_alpha(c, alpha[0]).abs_diff(times_alpha(w, alpha[0])) <= 1), "{out:?} {alpha:?}"); + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6529cf175d..beda6d8ade 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -490,6 +490,43 @@ fn trace_and_style_prompt_steps_run_in_order() { assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}"); } +/// A window capture and an artboard preview scaled down keep the color of opaque pixels beside +/// clear ones: the clear black beside them does not darken them. +#[test] +fn scaled_down_pictures_keep_the_color_beside_clear_pixels() { + struct Striped; + impl Backend for Striped { + fn call(&mut self, method: &str, _: Value) -> Result<Value, String> { + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = vectorcraft_format::base64_encode(png.get_ref()); + Ok(match method { + "document.inspect" => json!({"artboards": [{"width": 64, "height": 4}]}), + _ => json!({"pngBase64": png, "width": 64, "height": 4}), + }) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "test".into() + } + } + + let mut s = Server::new(Box::new(Striped)); + let shots = [("screenshot", json!({"window": true, "max_side": 16})), ("render_preview", json!({"max_side": 16}))]; + for (id, (tool, args)) in (1..).zip(shots) { + let r = call(&mut s, id, tool, args); + let data = + r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("{tool}: {r}"))["data"].as_str().unwrap(); + let img = image::load_from_memory(&vectorcraft_format::base64_decode(data).unwrap()).unwrap().into_rgba8(); + assert_eq!(img.dimensions(), (16, 1), "{tool}"); + assert!(img.pixels().all(|p| p.0 == [200, 30, 30, 128]), "{tool}: {:?}", img.get_pixel(0, 0)); + } +} + /// `screenshot` and `render_preview` with `traceViews: true` draw an Image Trace object in the /// view set with imageTrace.setView, as the app's window does; without it they draw the tracing /// result, as exports do. The image is a dark disc (r = 30 px) on blue, traced with Ignore White. diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index b1c5af70bb..8e514ac48d 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -4,6 +4,7 @@ use super::handlers::{ select_ids, transform, }; use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, json, writable_format}; +use vectorcraft_doc::resample::{Resample, fitted}; /// The `menu` argument of the command listings. pub(super) const MENU_FILTER: &str = @@ -445,7 +446,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> { let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?; // The render was asked for at most 4096 a side. let img = super::handlers::decode_png(&png, 8192).map_err(|e| format!("read preview: {e}"))?; - let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img }; + let img = if img.width() > max || img.height() > max { fitted(img, max, max, Resample::Average) } else { img }; let mut out = std::io::Cursor::new(Vec::new()); img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?; Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()}))) diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index 39d3f3f8e1..5aefa20d31 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -1,5 +1,6 @@ use super::dispatch::trace_views; use super::{Backend, Map, ToolResult, Value, json}; +use vectorcraft_doc::resample::{Resample, fitted}; // ---------- implementation ---------- @@ -521,7 +522,7 @@ fn fit_capture(png: Vec<u8>, max: Option<u32>, info: &mut Value) -> Result<Vec<u info["height"] = json!(img.height()); return Ok(png); } - let img = img.thumbnail(max, max); + let img = fitted(img, max, max, Resample::Average); info["width"] = json!(img.width()); info["height"] = json!(img.height()); let mut out = std::io::Cursor::new(Vec::new()); diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs index 4aeb5b4381..e5b3f5cfcc 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -4,6 +4,8 @@ use std::io::Cursor; +use vectorcraft_doc::resample::{Resample, resized}; + use crate::bytes::Reader; /// Most pixels along a side of an image the writers store (larger ones are scaled down). @@ -42,7 +44,8 @@ impl Rgba { let img = if k > 1.0 { let nw = ((f64::from(w) / k).floor() as u32).max(1); let nh = ((f64::from(h) / k).floor() as u32).max(1); - image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle) + let how = Resample::Filter(image::imageops::FilterType::Triangle); + resized(image::DynamicImage::ImageRgba8(img), nw, nh, how).into_rgba8() } else { img }; @@ -447,4 +450,18 @@ mod tests { let f = Rgba::fitted(img); assert_eq!((f.width, f.height), (MAX_SIDE, 2)); } + + /// A large image scaled to fit keeps the color of its opaque pixels beside clear ones: the + /// clear black beside them does not darken them. + #[test] + fn large_images_keep_their_color_beside_clear_pixels() { + let img = + image::RgbaImage::from_fn(MAX_SIDE * 2, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let f = Rgba::fitted(img); + assert_eq!((f.width, f.height), (MAX_SIDE, 2)); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for p in f.pixels.as_chunks::<4>().0 { + assert!(p[3] > 0 && [200, 30, 30].iter().zip(p).all(|(&want, &c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}"); + } + } } diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 4139259583..15585a2c20 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -21,7 +21,8 @@ use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageRea use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; use vectorcraft_doc::ImageBlob; -use vectorcraft_doc::cmyk::jpeg_components; +use vectorcraft_doc::cmyk::{Inks, jpeg_components}; +use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply}; use crate::output::{CmykPixels, Pixels}; use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; @@ -225,15 +226,29 @@ fn jpeg_quality(r: &Rule, jpeg: bool) -> Option<u8> { } } -/// `img` resampled to `w` × `h` pixels as `r` says. +/// How `r` resamples an image. +fn resampling(r: &Rule) -> Resample { + match r.downsample { + Downsample::Average => Resample::Average, + Downsample::Subsample => Resample::Filter(FilterType::Nearest), + Downsample::Bicubic | Downsample::None => Resample::Filter(FilterType::CatmullRom), + } +} + +/// `img` resampled to `w` × `h` pixels as `r` says, the colors of an image with alpha weighted by +/// it ([`resized`]). fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { + resized(img, w, h, resampling(r)) +} + +/// The four channels of `img` resampled to `w` × `h` pixels as `r` says, each on its own. +fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { if (w, h) == (img.width(), img.height()) { return img; } - match r.downsample { - Downsample::Average => img.thumbnail_exact(w, h), - Downsample::Subsample => img.resize_exact(w, h, FilterType::Nearest), - Downsample::Bicubic | Downsample::None => img.resize_exact(w, h, FilterType::CatmullRom), + match resampling(r) { + Resample::Average => img.thumbnail_exact(w, h), + Resample::Filter(f) => img.resize_exact(w, h, f), } } @@ -343,17 +358,10 @@ pub(crate) fn recode_cmyk( let Some(inks) = blob.cmyk() else { return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) }; }; - let dims = (inks.width, inks.height); - let (width, height) = sized(&rule, dims, size); - let alpha = match alpha.map(|a| image::GrayImage::from_raw(inks.width, inks.height, a)) { - Some(Some(a)) => Some(resample(DynamicImage::ImageLuma8(a), &rule, (width, height)).into_luma8().into_raw()), - Some(None) => return Recoded { image: None, warning: None }, - None => None, - }; - // The four inks resample as the four channels of an RGBA image do. - let inks = match image::RgbaImage::from_raw(inks.width, inks.height, inks.data) { - Some(img) => resample(DynamicImage::ImageRgba8(img), &rule, (width, height)).into_rgba8().into_raw(), - None => return Recoded { image: None, warning: Some(CMYK_RGB) }, + let (width, height) = sized(&rule, (inks.width, inks.height), size); + let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) { + Ok(r) => r, + Err(warning) => return Recoded { image: None, warning }, }; let (samples, space) = match convert { Some(p) => p.convert(&inks), @@ -365,6 +373,36 @@ pub(crate) fn recode_cmyk( Recoded { image, warning: rule.instead } } +/// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any. +type InksAndAlpha = (Vec<u8>, Option<Vec<u8>>); + +/// Ink amounts `inks` and their `alpha` (one sample a pixel) resampled to `to` as `r` says. The +/// inks under clear pixels don't count where neighbors blend: they are multiplied by the alpha +/// before resampling and divided by the resampled alpha after. `Err` with the warning when they +/// can't be read. +fn resampled_inks(inks: Inks, alpha: Option<Vec<u8>>, r: &Rule, to: (u32, u32)) -> Result<InksAndAlpha, Option<&'static str>> { + let dims = (inks.width, inks.height); + let weighted = to != dims && resampling(r) != Resample::Filter(FilterType::Nearest); + let mut data = inks.data; + let alpha = match alpha.map(|a| image::GrayImage::from_raw(dims.0, dims.1, a)) { + Some(Some(a)) => { + if weighted { + premultiply(&mut data, a.as_raw()); + } + Some(resample(DynamicImage::ImageLuma8(a), r, to).into_luma8().into_raw()) + } + Some(None) => return Err(None), + None => None, + }; + // The four inks resample as the four channels of an RGBA image do, each on its own. + let img = image::RgbaImage::from_raw(dims.0, dims.1, data).ok_or(Some(CMYK_RGB))?; + let mut inks = resample_channels(DynamicImage::ImageRgba8(img), r, to).into_rgba8().into_raw(); + if let Some(a) = alpha.as_deref().filter(|_| weighted) { + unpremultiply(&mut inks, a); + } + Ok((inks, alpha)) +} + /// The most one image read here may allocate (bytes), as the image reader allows by default. const MAX_DECODE: u64 = 512 << 20; @@ -422,3 +460,26 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual } Image::from_jpeg(out.into(), interpolate).ok() } + +#[cfg(test)] +mod tests { + use super::*; + + /// The inks of a CMYK image with alpha, resampled, keep the amounts of the opaque pixels: the + /// inks under the clear pixels beside them don't thin them out. + #[test] + fn resampled_inks_keep_the_amounts_beside_clear_pixels() { + let data = [[40, 80, 120, 160], [0, 0, 0, 0]].repeat(8).concat(); + let alpha = [255, 0].repeat(8); + for method in ["average", "bicubic"] { + let c: CompressionSettings = serde_json::from_value(serde_json::json!({"color": {"downsample": method}})).unwrap(); + let inks = Inks::new(16, 1, data.clone()).unwrap(); + let (inks, alpha) = resampled_inks(inks, Some(alpha.clone()), &rule(&c, Kind::Color), (4, 1)).unwrap(); + let alpha = alpha.unwrap(); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for (p, &a) in inks.as_chunks::<4>().0.iter().zip(&alpha) { + assert!(a > 0 && p.iter().zip([40, 80, 120, 160]).all(|(&c, want)| m(c, a).abs_diff(m(want, a)) <= 1), "{method}: {p:?} at {a}"); + } + } + } +} diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index c02f3f5e1c..076c3c08c9 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -73,6 +73,38 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() { assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi"); } +/// An image with clear pixels, resampled, keeps the color of its opaque pixels: the clear black +/// beside them does not darken them. +#[test] +fn resampled_images_keep_their_color_beside_clear_pixels() { + let striped = image::RgbaImage::from_fn(600, 600, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let d = doc(encoded(striped.into(), ImageFormat::Png), "image/png"); + for method in ["average", "bicubic"] { + let mut settings: PdfSettings = + serde_json::from_value(json!({"compression": {"color": {"downsample": method, "ppi": 150, "abovePpi": 225, "compression": "zip"}}})) + .unwrap(); + settings.compression.compress_text = false; + let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let rgb = file + .objects() + .into_iter() + .find_map(|o| match o { + Object::Stream(s) + if s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") && s.dict().contains_key(b"SMask") => + { + Some(s.decoded().unwrap().into_owned()) + } + _ => None, + }) + .unwrap_or_else(|| panic!("{method}: a color image with a soft mask")); + assert_eq!(rgb.len(), 150 * 150 * 3, "{method}"); + for p in rgb.as_chunks::<3>().0 { + assert!(p[0].abs_diff(200) <= 3 && p[1].abs_diff(30) <= 3 && p[2].abs_diff(30) <= 3, "{method}: {p:?}"); + } + } +} + /// The width and height of each image XObject in `pdf`. fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> { let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 1d39ece10c..c130fbe346 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -1,6 +1,7 @@ //! Paint conversion (solid, gradients, patterns) and image decoding. use vectorcraft_color::{GradientKind, Paint}; +use vectorcraft_doc::resample::{Resample, resized}; use vectorcraft_geom::{Rect, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko::{self, ColorStop}; @@ -446,7 +447,7 @@ fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage { } let s = max as f64 / w.max(h) as f64; let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max)); - img.resize_exact(nw, nh, image::imageops::FilterType::Triangle) + resized(img, nw, nh, Resample::Filter(image::imageops::FilterType::Triangle)) } /// [`decode_pixmap_or_why`]'s pixels. @@ -504,7 +505,7 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixm } }; let img = if img.width() > w as u32 || img.height() > h as u32 { - img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle) + resized(img, w as u32, h as u32, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img }; @@ -587,4 +588,35 @@ mod tests { assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC + 1), Err(Undecoded::NoRoom)); assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(())); } + + /// Opaque red beside clear black, every other column of a `w` × 1 image. + fn striped(w: u32) -> image::RgbaImage { + image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + } + + /// Does each pixel of `pm` hold red at its own coverage (premultiplied, within a step)? + fn red_at_its_coverage(pm: &vello_cpu::Pixmap) -> bool { + let m = |c: u8, a: u8| ((u16::from(c) * u16::from(a) + 127) / 255) as u8; + pm.data().iter().all(|p| p.r.abs_diff(m(200, p.a)) <= 1 && p.g.abs_diff(m(30, p.a)) <= 1 && p.b.abs_diff(m(30, p.a)) <= 1) + } + + /// A placed image drawn smaller keeps the color of its opaque pixels at its cut-out edges: the + /// clear pixels beside them do not darken them. + #[test] + fn an_image_drawn_smaller_keeps_its_color_beside_clear_pixels() { + let mut png = Vec::new(); + striped(8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let pm = decode_pixmap_fit(&png, 2, 1).unwrap(); + assert_eq!((pm.width(), pm.height()), (2, 1)); + assert!(pm.data().iter().all(|p| p.a > 0)); + assert!(red_at_its_coverage(&pm), "{:?}", pm.data()); + } + + /// An image wider than a pixmap may be is shrunk to fit with its colors weighted by alpha the same way. + #[test] + fn an_image_shrunk_to_fit_a_pixmap_keeps_its_color_beside_clear_pixels() { + let pm = decoded_pixmap(image::DynamicImage::ImageRgba8(striped(u32::from(u16::MAX) + 4465))).unwrap(); + assert_eq!(pm.width(), u16::MAX); + assert!(red_at_its_coverage(&pm)); + } } diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 3b55d34e11..9c10281783 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -30,6 +30,7 @@ use std::collections::HashMap; use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::Paint; +use vectorcraft_doc::resample::{Resample, fitted}; use vectorcraft_engine::cmd::library::{ItemKind, LIBRARY_EXT, LIBRARY_EXTS, Library, LibraryGraphic}; use crate::theme::Tokens; @@ -831,8 +832,7 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32) } let tex = vectorcraft_format::base64_decode(png).and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()).map(|img| { - let img = if img.width().max(img.height()) > px { img.resize(px, px, image::imageops::FilterType::Triangle) } else { img }; - let rgba = img.to_rgba8(); + let rgba = shrunk_thumbnail(img, px); let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); ctx.load_texture(format!("library-{library}-{item}-{px}"), image, egui::TextureOptions::LINEAR) }); @@ -846,6 +846,13 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32) tex } +/// Thumbnail `img` shrunk smoothly to at most `px` pixels a side, its colors weighted by its alpha +/// so that clear pixels don't darken the edges of the art, as RGBA. +fn shrunk_thumbnail(img: image::DynamicImage, px: u32) -> image::RgbaImage { + let img = if img.width().max(img.height()) > px { fitted(img, px, px, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img }; + img.into_rgba8() +} + /// Graphic `item` of `library`'s thumbnail in `r` (the chip a dragged graphic shows at the pointer). pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item: &str) { if let Some(g) = app.session.libraries.get(library).and_then(|l| l.graphics.iter().find(|g| g.id == item)) { diff --git a/crates/ui-egui/src/panels/libraries/tests.rs b/crates/ui-egui/src/panels/libraries/tests.rs index 931a91a444..00ff1bf2cd 100644 --- a/crates/ui-egui/src/panels/libraries/tests.rs +++ b/crates/ui-egui/src/panels/libraries/tests.rs @@ -525,3 +525,16 @@ fn libraries_are_exported_and_imported_from_the_panel_menu() { assert_eq!(names, ["Brand", "Brand (2)"]); assert_eq!(app.ui.status, "Imported library Brand (2)"); } + +/// A graphic's thumbnail shrunk for its tile keeps the color of the art beside clear pixels: the +/// clear black beside it does not darken it. +#[test] +fn a_shrunk_thumbnail_keeps_its_color_beside_clear_pixels() { + let img = image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let small = shrunk_thumbnail(image::DynamicImage::ImageRgba8(img), 16); + assert_eq!(small.dimensions(), (16, 1)); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for p in small.pixels() { + assert!(p[3] > 0 && [200, 30, 30].iter().zip(p.0).all(|(&want, c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}"); + } +} From 765a98e49e6bf3b93b45e50f3813894a1960db46 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:09:42 -0800 Subject: [PATCH 51/89] PDF/.ai export: give the present reason area type with wrap shapes is written as outlines The text document of the editing data does not hold an area text object's wrap shapes. On reopening, the reader works them out again from the art above the type, which can wrap it differently (a live object written as its drawn pieces) or not at all. The comments on the check in holds and in a thread's frame check now say so, and the outlines test's comment no longer says the wrap object's settings are not written. --- crates/eps/src/ai_editing/tests.rs | 4 ++-- crates/eps/src/ai_editing/writer/texts.rs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a3b2800fd3..0fba16781c 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4473,8 +4473,8 @@ fn type_the_data_cant_hold_is_written_as_its_outlines() { rings.subpaths.extend(shapes::rectangle(Rect::new(120.0, 30.0, 170.0, 70.0)).subpaths); holed.kind = vectorcraft_doc::TextKind::Area { frame: rings }; holed.xf = Affine::IDENTITY; - // A carriage return in the characters: a story ends its paragraphs with it. Area type that - // flows around a wrap object: the wrap object's settings aren't written. + // A carriage return in the characters: a story ends its paragraphs with it. Area type with + // wrap shapes and no wrap object: the text document doesn't hold the shapes. let returned = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "A\rB", style(10.0, Color::BLACK)); let mut wrapped = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped round", style(10.0, Color::BLACK)); wrapped.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)) }; diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 6bc3c63b09..885af725b7 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -204,7 +204,9 @@ pub(in crate::ai_editing) fn holds(n: &Node, t: &TextObject) -> bool { shape(t).as_ref().and_then(segments).is_some_and(|s| !(open && closed(&s))) } }; - // Area type that flows around wrap objects: the data doesn't hold their wrap settings. + // Area type with wrap shapes: the text document doesn't hold them. The reader works them out + // again from the art above the type, which can wrap it differently (a live object written as + // its drawn pieces) or not at all. shape_ok && t.wrap.is_empty() && t.ruby.is_empty() @@ -962,6 +964,7 @@ impl TextDoc { matches!(t.kind, TextKind::Area { .. }) && t.vertical == vertical && writable(n.id) + // The text document doesn't hold wrap shapes (see `holds`). && t.wrap.is_empty() && plain(n) && t.ruby.is_empty() From 87900ab512076a59551a1e91f414256d4a2b6683 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 04:25:22 -0800 Subject: [PATCH 52/89] Export: a page of the art's bounds is measured on the art the file draws With useArtboards off, PDF, .ai and HP-GL exports sized their one page by every visible object, including art they then leave out. A PDF without includeNonPrinting or createLayers drops non-printing layers, so art on such a layer made the page larger than what was drawn, and a document whose only visible art did not print came out as a blank page. An HP-GL plot draws nothing of non-printing layers, objects at 0% opacity, placed images, gradient meshes whose every point is at 0% opacity and paths that paint nothing, and such art moved the plot's origin. The page is now measured with that art hidden, and a document with no other visible art is refused. A .ai file is measured with the PDF options it is written with, so it keeps non-printing layers unless both createLayers and includeNonPrinting are off. The PDF writer's own non-printing filter uses the same helper, and the HP-GL writer's plots_nothing says which objects a plot draws nothing of. Export As measures an HP-GL plot of the art's bounds the same way, so a document with nothing the plot draws stays in the dialog before the save panel. --- crates/cad/src/hpgl/write.rs | 17 ++++++ crates/cad/src/hpgl/write/tests.rs | 66 ++++++++++++++++++++++ crates/engine/src/cmd/fileio.rs | 5 +- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/encode.rs | 64 +++++++++++++++++++-- crates/engine/src/cmd/fileio/pdf.rs | 40 +++++-------- crates/engine/src/cmd/fileio/save.rs | 21 +++++-- crates/engine/src/cmd/fileio/tests_hpgl.rs | 51 +++++++++++++++++ crates/engine/src/cmd/fileio/tests_pdf.rs | 42 ++++++++++++++ crates/ui-egui/src/dialogs/export_as.rs | 5 +- crates/ui-egui/src/dialogs/tests_export.rs | 17 ++++++ docs/mcp.md | 9 ++- docs/target-app-parity.md | 1 + 13 files changed, 299 insertions(+), 41 deletions(-) diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 6ab80c8ba0..7d753e1801 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -1024,6 +1024,23 @@ fn overlaps(a: Rect, b: Rect) -> bool { a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 } +/// Whether a plot draws nothing of `n` wherever it is, apart from what every export leaves out +/// (hidden objects, guides and template layers): an object at 0% opacity, a non-printing layer, a +/// placed image, a gradient mesh whose every point is at 0% opacity, or a path or compound path +/// without effects whose fills and strokes paint nothing. A clipping path still clips when the +/// plot draws nothing of it. +pub fn plots_nothing(n: &Node) -> bool { + let unpainted = || n.appearance.effects.is_empty() && top_stroke(n).is_none() && top_fill(n).is_none(); + n.opacity <= 0.0 + || match &n.kind { + NodeKind::Layer { printable, .. } => !printable, + NodeKind::Image(_) => true, + NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity <= 0.0), + NodeKind::Path { .. } | NodeKind::Compound { .. } => unpainted(), + _ => false, + } +} + /// `nodes` (the children of a clipping container when `clipping`) without the layers and /// sublayers a plot leaves out (hidden, non-printing, template or at 0% opacity) and without /// guides, in layers and groups at any depth, so that baking can't wrap one in art that is drawn diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 9cb4f7d727..c600a60eab 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -177,6 +177,72 @@ fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() { assert_eq!(t.matches("PD").count(), 3, "{t}"); } +/// `plots_nothing` picks the objects and layers that a plot draws nothing of: a path that paints +/// nothing, objects at 0% opacity, placed images, meshes with every point at 0% opacity and +/// non-printing layers, and not a painted path or a mesh. +#[test] +fn plots_nothing_picks_what_a_plot_draws_nothing_of() { + type Make = fn(&mut Document) -> Node; + let cases: [(&str, Make, bool); 6] = [ + ("stroked", |d| stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK), false), + ("unpainted", |d| Node::path(d.alloc_id(), rect(10.0, 10.0, 40.0, 40.0), Appearance::basic(Paint::None, Paint::None, 1.0)), true), + ( + "clear", + |d| { + let mut n = filled(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK); + n.opacity = 0.0; + n + }, + true, + ), + ( + "image", + |d| { + let image = vectorcraft_doc::ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))) + }, + true, + ), + ("mesh", |d| Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), false), + ( + "clear mesh", + |d| { + let mut clear = mesh_at(10.0); + clear.points.iter_mut().for_each(|p| p.opacity = 0.0); + Node::new(d.alloc_id(), NodeKind::Mesh(clear)) + }, + true, + ), + ]; + for (name, make, nothing) in cases { + let mut held = None; + let d = doc_with(|d| { + let n = make(d); + held = Some(n.clone()); + vec![n] + }); + let Some(n) = held else { panic!("{name}") }; + assert_eq!(plots_nothing(&n), nothing, "{name}"); + assert_eq!(text(&plot(&d, &opts(&d))) == "IN;PU;SP0;", nothing, "{name}"); + } + let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK)]); + let layer = d.layers[0].id; + assert!(!plots_nothing(&d.layers[0])); + if let Some(NodeKind::Layer { printable, .. }) = d.node_mut(layer).map(|n| &mut n.kind) { + *printable = false; + } + assert!(plots_nothing(&d.layers[0])); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); +} + /// Live art that makes more objects than the plot's limit (1,000 in tests) is left out with a /// warning. #[test] diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 1ff1897c85..586b1130a2 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -66,7 +66,10 @@ use serde_json::{Value, json}; pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; -pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts}; +pub use encode::{ + ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, export_art_bounds, + note_texts, +}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated}; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 03736c6022..e8ed4d8faa 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: of the layers the file draws, without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the art it plots, without placed images, unpainted paths and art at 0% opacity), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0fbffcea82..cc8fc60f8f 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -2,11 +2,12 @@ //! (unknown keys are ignored, so one params object can carry the options of several formats). use std::borrow::Cow; +use std::sync::Arc; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; -use vectorcraft_doc::{Artboard, Document}; +use vectorcraft_doc::{Artboard, Document, Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; @@ -419,13 +420,15 @@ pub(super) fn use_artboards(cmd: &str, p: &Value) -> Result<Option<bool>> { /// With `useArtboards: false` in `p`: `doc` without its opacity-mask or symbol editing layer and /// with one artboard, the bounds of its visible art, and `p` without the artboard choice. `None` -/// when `useArtboards` is absent, null or true. A document with no visible art is refused. -pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(Document, Value)>> { +/// when `useArtboards` is absent, null or true. The objects that `left_out` picks (layers or +/// objects the format leaves out, see [`hidden_nodes`]) are not measured, and a document with no +/// other visible art is refused. +pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value, left_out: impl Fn(&Node) -> bool) -> Result<Option<(Document, Value)>> { if use_artboards(cmd, p)? != Some(false) { return Ok(None); } let doc = &*doc.without_edit_modes(); - let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?; + let bounds = bounds_without(doc, &left_out).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?; let mut q = super::export::without_artboards(p); if let Some(o) = q.as_object_mut() { o.remove("useArtboards"); @@ -433,6 +436,27 @@ pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(D Ok(Some((single_artboard(doc, bounds, "Art"), q))) } +/// The bounds of the visible art of `doc` that a `format` file of the art's bounds draws, as +/// [`art_page`] measures it with that format's default options: for HP-GL without what a plot +/// leaves out ([`vectorcraft_cad::hpgl::write::plots_nothing`]), for other formats all of it (the +/// non-printing layers a PDF leaves out depend on options chosen after the save panel). `None` +/// when there is none, which such an export refuses. +pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> { + let doc = &*doc.without_edit_modes(); + bounds_without(doc, &|n| format == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(n)) +} + +/// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden +/// ([`hidden_nodes`]). +fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> { + let drawn = hidden_nodes(&doc.layers, left_out).map(|layers| { + let mut d = doc.clone(); + d.layers = layers; + d + }); + vectorcraft_render::encode::art_bounds(drawn.as_ref().unwrap_or(doc)) +} + /// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when /// absent; an error when it is not four finite numbers with a positive size, when the format isn't /// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would @@ -481,6 +505,35 @@ pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> D d } +/// `nodes` with the visible objects that `left_out` picks hidden, in layers, sublayers and groups +/// that don't clip, at any depth (what an object it picks holds is not looked at); `None` when it +/// picks none of them. Clipping layers are looked in too, as their sublayers can be picked. +pub(super) fn hidden_nodes(nodes: &[Arc<Node>], left_out: &dyn Fn(&Node) -> bool) -> Option<Vec<Arc<Node>>> { + let mut out: Option<Vec<Arc<Node>>> = None; + for (i, l) in nodes.iter().enumerate() { + let new = if left_out(l) { + l.visible.then(|| { + let mut n = (**l).clone(); + n.visible = false; + Arc::new(n) + }) + } else { + let (NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false }) = &l.kind else { continue }; + hidden_nodes(children, left_out).map(|c| { + let mut n = (**l).clone(); + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut n.kind { + *children = c; + } + Arc::new(n) + }) + }; + if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| nodes.to_vec()).get_mut(i)?, new))) { + *slot.0 = slot.1; + } + } + out +} + fn boards<T>(r: std::result::Result<T, String>) -> Result<T> { r.map_err(|e| bad(C, e)) } @@ -636,8 +689,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { { return Ok(enc); } + let left_out = |n: &Node| f.id == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(n); if use_artboards == Some(false) - && let Some((art, q)) = art_page(C, doc, p)? + && let Some((art, q)) = art_page(C, doc, p, left_out)? { return encode_files(&art, f, &q); } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index c834e653e4..6dc8a83641 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -289,7 +289,7 @@ pub const EDITING_NEEDS_THE_ARTBOARDS: &str = /// Editing on, the notes end with [`EDITING_NEEDS_THE_ARTBOARDS`]. `None` when `p` asks for the /// artboards. pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(Vec<u8>, Vec<Note>)>> { - let Some((art, mut q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let Some((art, mut q)) = art_page(cmd, doc, p)? else { return Ok(None) }; let left_out = settings(cmd, &q)?.preserve_editing; if left_out && let Some(o) = q.as_object_mut() { o.insert("preserveEditing".into(), json!(false)); @@ -301,6 +301,18 @@ pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Op Ok(Some((bytes, notes))) } +/// [`super::encode::art_page`] of a PDF with the options in `p`: the bounds of the visible art on +/// the layers its pages draw, without the non-printing ones unless `includeNonPrinting` or +/// `createLayers` keeps them. +pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(Document, Value)>> { + if super::encode::use_artboards(cmd, p)? != Some(false) { + return Ok(None); + } + let set = settings(cmd, p)?; + let keeps = set.include_non_printing || set.create_layers; + super::encode::art_page(cmd, doc, p, |l| !keeps && matches!(l.kind, NodeKind::Layer { printable: false, .. })) +} + /// The warning for a PDF/X file that leaves out the document's bleed /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; @@ -477,29 +489,7 @@ fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result<Vec<Thum /// `layers` with the non-printing layers among them (at any depth) hidden; `None` when there are /// none. fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> { - let mut out: Option<Vec<Arc<Node>>> = None; - for (i, l) in layers.iter().enumerate() { - let NodeKind::Layer { printable, children, .. } = &l.kind else { continue }; - let new = if !printable { - l.visible.then(|| { - let mut n = (**l).clone(); - n.visible = false; - Arc::new(n) - }) - } else { - without_non_printing(children).map(|c| { - let mut n = (**l).clone(); - if let NodeKind::Layer { children, .. } = &mut n.kind { - *children = c; - } - Arc::new(n) - }) - }; - if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) { - *slot.0 = slot.1; - } - } - out + super::encode::hidden_nodes(layers, &|l| matches!(l.kind, NodeKind::Layer { printable: false, .. })) } fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index af9ed8e58a..e4a72ffc35 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -757,8 +757,9 @@ pub const AI_EDITING_NEEDS_THE_ARTBOARDS: &str = pub const ART_PAGE_NEEDS_PDF: &str = "pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false"; /// With `useArtboards: false` in `p`: the PDF-compatible `.ai` file of one page, the bounds of -/// `doc`'s visible art ([`super::encode::art_page`]). It carries neither the native document nor -/// the editing copy for other apps, and its notes end with [`AI_EDITING_NEEDS_THE_ARTBOARDS`]. +/// `doc`'s visible art on the layers its pages draw ([`super::pdf::art_page`]). It carries neither +/// the native document nor the editing copy for other apps, and its notes end with +/// [`AI_EDITING_NEEDS_THE_ARTBOARDS`]. /// `pdfCompatible: false` is refused. `None` when `p` asks for the artboards or for a legacy /// version 7 `.ai` file. pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Option<Encoded>> { @@ -768,7 +769,7 @@ pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Valu if !bool_or(p, "pdfCompatible", true) { return Err(bad(cmd, ART_PAGE_NEEDS_PDF)); } - let Some((art, q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let Some((art, q)) = super::pdf::art_page(cmd, doc, &ai_pdf_params(p, false))? else { return Ok(None) }; let mut enc = encode_ai_pages(cmd, f, &art, &q, false)?; enc.setting_notes.push(AI_EDITING_NEEDS_THE_ARTBOARDS.to_string()); Ok(Some(enc)) @@ -784,9 +785,10 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res encode_ai_pages(cmd, f, doc, p, true) } -/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and -/// the editing copy for other apps when `editing` is true, and neither otherwise. -fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result<Encoded> { +/// `p` with the PDF options a `.ai` file is written with: Preserve Editing when `editing` is true, +/// layers unless a standard decides, long pages with a `UserUnit`, and `compress` as +/// `compression.compressText`. +fn ai_pdf_params(p: &Value, editing: bool) -> Value { let mut params = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = params.as_object_mut() { o.insert("preserveEditing".into(), json!(editing)); @@ -805,6 +807,13 @@ fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bo } } } + params +} + +/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and +/// the editing copy for other apps when `editing` is true, and neither otherwise. +fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result<Encoded> { + let params = ai_pdf_params(p, editing); let pdf_compatible = bool_or(¶ms, "pdfCompatible", true); let f = &if pdf_compatible { *f } else { without_pdf(f) }; let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index f4767fd81d..09fe5e132c 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -170,6 +170,57 @@ fn a_plot_reads_back_as_its_art() { assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}"); } +/// A plot of the art's bounds is measured on the art it plots: art on a non-printing layer, on a +/// layer at 0% opacity or at 0% opacity in a group, a placed image and a path that paints nothing +/// don't move its origin, a clipping layer is measured as clipped, and a document whose only +/// visible art is such art is refused. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_the_layers_it_does_not_plot() { + let mut s = two_boards(); + let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v)); + let alone = plot(&mut s).unwrap(); + let printing = s.doc().unwrap().doc.layers[0].id; + for props in [json!({"printable": false}), json!({})] { + let layer = s.execute("layer.new", &json!({})).unwrap()["id"].clone(); + if props.as_object().unwrap().is_empty() { + s.execute("transparency.set", &json!({"ids": [layer], "opacity": 0})).unwrap(); + } else { + let mut p = props.clone(); + p["id"] = layer.clone(); + s.execute("layer.setProps", &p).unwrap(); + } + s.execute("shape.rectangle", &json!({"x": -300, "y": 500, "width": 20, "height": 20})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "{props}"); + } + // An object at 0% opacity in a group, on a layer that plots. + s.execute("layer.new", &json!({})).unwrap(); + let far = s.execute("shape.rectangle", &json!({"x": -500, "y": 700, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("object.group", &json!({"ids": [far]})).unwrap(); + s.execute("transparency.set", &json!({"ids": [far], "opacity": 0})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "0% opacity in a group"); + // A placed image and a path whose fill and stroke paint nothing. + let mut png = vec![]; + image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "a placed image"); + let bare = s.execute("shape.rectangle", &json!({"x": 500, "y": -400, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [bare], "none": true})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "a path that paints nothing"); + + // A clipping layer's clipping path, which loses its paint, still clips the art measured: a + // large square clipped to the first square draws nothing more. + let clipping = s.execute("layer.new", &json!({})).unwrap()["id"].clone(); + s.execute("shape.rectangle", &json!({"x": -300, "y": -300, "width": 800, "height": 800})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("layer.clippingMask.toggle", &json!({"id": clipping})).unwrap(); + assert_eq!(plot(&mut s).unwrap(), alone, "a clipping layer"); + + s.execute("layer.setProps", &json!({"id": clipping, "visible": false})).unwrap(); + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); +} + #[test] fn export_options_are_checked() { let mut s = two_boards(); diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index f57e02fc2d..f5b1b8e34d 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -103,6 +103,48 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() { } } +/// A page of the art's bounds is measured on the layers the PDF draws: a non-printing layer that +/// the PDF leaves out doesn't make it larger, unless `includeNonPrinting` or `createLayers` keeps +/// the layer. A `.ai` file of the art's bounds, which keeps layers by default, measures it unless +/// `createLayers` is off. A document whose only visible art doesn't print is refused. +#[test] +fn a_page_of_the_arts_bounds_leaves_out_non_printing_layers() { + let mut s = session(1); + let printing = s.doc().unwrap().doc.layers[0].id; + let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone(); + s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap(); + let size = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().artboards[0].rect.size(); + let printed = size(&b64(&export(&mut s, json!({"useArtboards": false})))); + assert!(printed.width < 50.0 && printed.height < 40.0, "{printed:?}"); + let through_export = s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap(); + assert_eq!(size(&b64(&through_export)), printed); + let ai = |s: &mut Session, extra: Value| { + let mut p = json!({"format": "ai", "useArtboards": false}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + size(&b64(&s.execute("document.export", &p).unwrap_or_else(|e| panic!("{p}: {e}")))) + }; + assert_eq!(ai(&mut s, json!({"createLayers": false})), printed); + let kept = [ + size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))), + size(&b64(&export(&mut s, json!({"useArtboards": false, "createLayers": true})))), + ai(&mut s, json!({})), + ]; + for all in kept { + assert!(all.width > 200.0 && all.height > 200.0, "{all:?}"); + } + + // Only the non-printing layer shows: the PDF would draw nothing. + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + for p in [json!({"useArtboards": false}), json!({"useArtboards": false, "includeNonPrinting": false})] { + let e = s.execute("document.exportPdf", &p).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{p}: {e}"); + } + let e = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "createLayers": false})).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + assert!(size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))).width < 25.0); +} + /// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a /// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5 /// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index bd9ee6ca49..492d0701e0 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -138,7 +138,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { } // Nothing to export keeps the dialog open too (before the save dialog, not after it). An // exporter plug-in decides for itself what it can write. - if !use_artboards && plugin.is_none() && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) { + if !use_artboards + && plugin.is_none() + && app.session.active().is_some_and(|s| vectorcraft_engine::cmd::fileio::export_art_bounds(&s.doc, f.id).is_none()) + { return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into()); } app.ui.dialog = None; diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index ca37940601..f8a2e05515 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -304,6 +304,23 @@ fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() { assert!(written.borrow().is_empty()); } +/// Art that a plot leaves out (here at 0% opacity) is nothing to export as HP-GL of the art's +/// bounds, before the save dialog; other formats still export it. +#[test] +fn art_a_plot_leaves_out_keeps_hpgl_export_as_open_before_the_save_dialog() { + let (mut app, _) = app(1); + app.services.pick_save = Some(Box::new(|_: &crate::FilePick| panic!("no save dialog without art the plot draws"))); + app.run("select.all", json!({})).unwrap(); + app.run("transparency.set", json!({"opacity": 0})).unwrap(); + app.run("file.exportAs", json!({"format": "hpgl"})).unwrap(); + assert!(confirm(&mut app).unwrap_err().contains("no visible art")); + assert_eq!(kind(&app), Some("exportAs")); + app.services.pick_save = Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))); + set(&mut app, "format", json!("png")); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("pngOptions")); +} + #[test] fn pdf_artboards_continue_in_the_save_pdf_dialog() { let (mut app, written) = app(3); diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..f2e08087bf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1066,7 +1066,12 @@ or `range`), named `<file>-<artboard>.<ext>`, and returns `{path, files}` (a PDF SVG writes one file per artboard of a `range`); `useArtboards: false` exports the bounds of the visible art with what its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same way. `document.exportPdf {useArtboards: false}` writes that one page as well, and ignores `artboard`, `artboards` and -`range`. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it +`range`. A PDF or `.ai` page of the art's bounds is measured without the non-printing layers that the file leaves out +(unless `includeNonPrinting` or `createLayers` keeps them; a `.ai` file keeps layers by default). An HP-GL plot of the +art's bounds is measured without what the plot leaves out: non-printing layers, objects at 0% opacity, placed images, +gradient meshes whose every point is at 0% opacity, and paths without effects whose fills and strokes paint nothing. +A document whose only visible art is left out is refused, and Export As refuses an HP-GL plot of it before the save +dialog. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with `useArtboards: false` writes that one page too, without the native document and without the editing copy for other apps (`editingData`), and always comes back with the warning `Preserve editing was left out: an .ai file of the art's @@ -7763,7 +7768,7 @@ instructions is refused with "not an HP-GL file". `document.export` (and `export`, and Export As) with format `hpgl` writes a plot file (`.plt`) of the first artboard (or `artboard`) for pen plotters and cutters; `useArtboards: true` writes one file per chosen artboard -(`{stem}-{artboard}.plt`), `false` the bounds of the visible art. Each visible path is drawn once along its own line: +(`{stem}-{artboard}.plt`), `false` the bounds of the art it plots. Each visible path is drawn once along its own line: a filled shape is its closed outline, also when a stroke gives its color, and a stroked path is its center line, so fills, stroke widths, dashes, brushes and arrowheads are not painted. Type is drawn as its glyph outlines, and a gradient mesh as its outline, also inside a blend (a mesh whose every point is at 0% opacity draws nothing). A diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..fd2c2ae9bd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1712,6 +1712,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, on by default; `.ai` saves leave it off) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard, so readers that ignore `UserUnit` show them at full size. Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`). A `.ai` export with Use Artboards off writes one page of the visible art's bounds without the native document or the editing copy for other apps, with a warning, so the file does not reopen with one artboard in place of the document's; without PDF content it is refused (`crates/engine/src/cmd/fileio/tests_pdfedit.rs`). + That page, and an HP-GL plot of the art's bounds, are measured on the art the file draws: non-printing layers the PDF or `.ai` file leaves out do not make it larger, and nor do the non-printing layers, objects at 0% opacity, placed images, clear gradient meshes and unpainted paths a plot leaves out; Export As refuses an HP-GL plot of nothing it draws before the save dialog (`crates/engine/src/cmd/fileio/tests_pdf.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`, `crates/ui-egui/src/dialogs/tests_export.rs`). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. In a PDF without a standard, the warning for a page longer than 14,400 pt written with a `UserUnit` names "Write pages longer than 200 in at full size in points" (`crates/engine/src/cmd/fileio/tests_pdf.rs`). A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`). From f97998e64a827cc5c6bca98b36219e39f09e0acb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:29:13 -0800 Subject: [PATCH 53/89] Docs: the menu-stub list names only menus that are not wired up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The From the code bullet in docs/target-app-parity.md listed SVG Filters, Graph › Column, Live Paint Gap Options, Optical Margin Alignment, the global and video rulers, Window › Arrange, SVG Interactivity, Other Script… and Start Global Edit as menu stubs, and repeated half the list on two broken lines. All of those are bound to commands or panels now. The list names the three menu items that are still todo entries: Effect › 3D and Materials › Materials, Edit › Spelling › Check Spelling and Help › VectorCraft Help. The dated counts are unchanged. --- docs/target-app-parity.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad7e9f4eab..f1d33cff0e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -204,11 +204,8 @@ tests and the CI gate. See [Calibration](#calibration). - **From the code (measured, origin/main `01e165af`):** - Menu tree (`crates/ui-egui/src/menus.rs`, laid out in Illustrator's menu order): 348 items bound to commands, 26 stubs (`todo(…)`/`todos(…)`), so 93.0% of menu items do something (348 and 26 after Snap to Glyph shipped in #3124; 348 and 27 after the - Revolve merge; 350 and 25, 93.3%, after Graph › Design and Marker; 353 and 22, 94.1%, after Extrude & Bevel, Inflate and Rotate). The stubs: Effect › 3D and Materials › Materials, Effect › SVG Filters, Edit › Spelling (3), Graph › Column, Live Paint - Gap Options, Optical Margin Alignment, global and video rulers (2), - Window › Arrange (4), SVG Interactivity, Other Script…, Help. - Gap Options, Optical Margin Alignment, Start Global Edit, global and video rulers (2), - Window › Arrange (4), SVG Interactivity, Other Script…, Help (the Brush and Symbol Libraries placeholders are gone: Window › Symbol Libraries works since 2026-10-23, Window › Brush Libraries since 2026-10-24). + Revolve merge; 350 and 25, 93.3%, after Graph › Design and Marker; 353 and 22, 94.1%, after Extrude & Bevel, Inflate and Rotate). The menu stubs on `a47d7eec` are Effect › 3D and Materials › Materials, + Edit › Spelling › Check Spelling and Help › VectorCraft Help. - Engine commands: ~710 ids registered with `cmd!` across `crates/engine/src/cmd` (plus macro-generated families), and 85 UI-only commands (`UI_COMMANDS`). - Tools: 91 in `crates/tools/src/catalog.rs`; Touch Type is listed but not implemented, and the Dimension From f00ddcbaa9c33a707de03fd684e87d51a596056a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:32:43 -0800 Subject: [PATCH 54/89] Effects: each copy of a Transform effect draws the raster effects listed before it A path's Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect that makes copies was drawn once under all the copies, at the object's own distances. Each copy now carries those raster effects, scaled by the copy's mean scale, with a Drop Shadow's offset turned, reflected and scaled with the copy. Raster effects listed after the Transform effect still paint once around all the copies. Copies that stop at the effect budget keep their effects, and a Transform effect without copies draws as before. The canvas, the object's bounds, export, baking and Expand Appearance use the same art. Type, images, symbol instances, groups, a fill's or stroke's own copying Transform effect, and paths with Rasterize or an SVG filter before the copying effect keep one set of raster effects. --- crates/effects/src/lib.rs | 4 +- crates/effects/src/reshape.rs | 130 ++++++++++++++++++++++- crates/effects/src/tests/rasterfollow.rs | 98 ++++++++++++++++- crates/engine/src/tests/objexpand.rs | 21 ++++ crates/render/src/fx/bounds.rs | 7 ++ crates/render/src/fx/renderer.rs | 5 +- crates/render/src/tests/objectfx.rs | 23 ++++ docs/target-app-parity.md | 1 + 8 files changed, 278 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c18a31d842..5163e06a33 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -81,8 +81,8 @@ pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry, - has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, - reshape, + has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outer_effects, outline_art, + outline_text, reshape, }; pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 83ff68431f..172df5d5e6 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -107,11 +107,44 @@ fn reaches_appearance(e: &Effect) -> bool { } /// Is `n` a path or compound path that its Transform effects make into art of separate objects -/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`]), or whose -/// live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, baked and -/// expanded through that art ([`reshape`]). +/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`], [`copied_raster_at`]), +/// or whose live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, +/// baked and expanded through that art ([`reshape`]). pub fn has_transform_art(n: &Node) -> bool { - has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n) + has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n) || copied_raster_at(n).is_some() +} + +/// Where the own effects of `n`, a path or compound path, split so that each copy of a Transform +/// effect carries the raster effects listed before that effect ([`copied_raster_art`]): the index +/// of the last visible Transform effect that makes copies, when a visible raster effect that +/// follows a Transform effect ([`crate::raster::follows`]) is listed before it and every other +/// visible effect before it is a geometry effect or such a raster effect. `None` otherwise, and for +/// a path with a linked opacity mask, a 3D effect, Crop Marks, Vector Halftone, a live-effect +/// plug-in that paints what it returns, or a fill or stroke that paints with geometry effects of +/// its own. +pub(crate) fn copied_raster_at(n: &Node) -> Option<usize> { + if !matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) { + return None; + } + let effects = &n.appearance.effects; + // Checked first: this runs for every object drawn. + if !effects.iter().any(|e| e.visible && crate::raster::follows(&e.id)) || !effects.iter().any(|e| e.visible && e.id == "distort.transform") { + return None; + } + let t = effects.iter().rposition(|e| { + e.visible && e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0 + })?; + let before = effects.get(..t)?; + let carried = before.iter().any(|e| e.visible && crate::raster::follows(&e.id)) + && before.iter().all(|e| !e.visible || is_geometry(&e.id) || crate::raster::follows(&e.id)); + let excluded = n.extras().mask.as_ref().is_some_and(|m| m.linked) + || crate::has_solid(n) + || crate::has_revolve(n) + || crate::has_crop_marks(n) + || crate::addons::halftone::has_halftone(n) + || has_painted_effects(n) + || n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())); + (carried && !excluded).then_some(t) } /// Is `e` a visible live-effect plug-in whose manifest declares `paintOutput`: its id's plug-in id, @@ -787,6 +820,9 @@ fn as_art_opts( nodes: &SymbolNodes, apart: bool, ) -> Option<Node> { + if let Some(t) = copied_raster_at(n) { + return Some(dressed_keeping_effects(copied_raster_art(paint_doc, n, t, apart), n)); + } let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -872,6 +908,89 @@ fn dressed(mut art: Node, n: &Node) -> Node { art } +/// [`dressed`] that keeps the effects of `art`. +fn dressed_keeping_effects(mut art: Node, n: &Node) -> Node { + let effects = std::mem::take(&mut art.appearance.effects); + let mut art = dressed(art, n); + art.appearance.effects = effects; + art +} + +/// The art of path or compound path `n` whose own effects split at `t` ([`copied_raster_at`]): the +/// art the effects before the Transform effect at `t` make of `n`, with their raster effects, then +/// copied by that Transform effect, each copy an object of its own carrying those raster effects +/// moved, turned, reflected and scaled with it ([`follow_art`]), and then reshaped by the geometry +/// effects after it. The art keeps `n`'s id and carries the raster effects from `t` on, without +/// `n`'s name, visibility, transparency and opacity mask. The copies stop where [`copy_art`]'s do, +/// each with its raster effects. With `apart`, the Transform effects before `t` make each copy an +/// object of its own ([`copy_leaf`]). +fn copied_raster_art(doc: Option<&Document>, n: &Node, t: usize, apart: bool) -> Node { + let (before, rest) = n.appearance.effects.split_at(t); + let mut source = n.clone(); + source.appearance.effects = before.to_vec(); + let piece = match copied_raster_at(&source) { + Some(at) => copied_raster_art(doc, &source, at, apart), + None => { + let mut art = one_piece(&source); + let carry = reshape_leaves_apart(&mut art, &geometry_effects(&source), &source.appearance.effects, Budget::EFFECTS, apart, doc); + if apart { + hoist_copies(&mut art); + } + transform_items(&mut art, doc); + let mut ran = source.appearance.effects.clone(); + if carry.skipped() { + carry.hide_skipped(&mut ran); + } + art.appearance.effects = crate::raster::without_geometry(&ran); + art + } + }; + let mut art = Node::group(n.id, vec![Arc::new(piece)]); + if let (Some(e), Some(bounds)) = (rest.first(), art.geometric_bounds()) { + let params = merged_params(&e.id, &e.params); + let (m, copies) = crate::distort::transform_map(bounds, ¶ms, n.id.0); + let options = crate::distort::TransformOptions::of(¶ms); + // The raster effects follow the copies below; Scale Strokes & Effects scales the others. + let mut scaling = copy_scaling(options); + scaling.effects = options.strokes.then_some(scale_unfollowed as fn(&mut Effect, f64)); + let max = Budget::EFFECTS.at_least(art_cost(&art)); + copy_art(&mut art, m, copies, scaling, max); + if let Some(children) = art.children_mut() { + // The original is last, and each copy above it is mapped once more by `m`. + let step = m.with_translation(vectorcraft_geom::Vec2::ZERO); + let mut a = Affine::IDENTITY; + let none = HashSet::new(); + for (k, c) in children.iter_mut().rev().enumerate() { + if k > 0 { + a *= step; + follow_art(Arc::make_mut(c), a, &none, Some(&none), None, 0); + } + // Each copy is the piece in a group of its own: the piece takes its place. + if let Some([one]) = c.children().map(Vec::as_slice) { + *c = one.clone(); + } + } + } + } + let later: Vec<&Effect> = rest.iter().skip(1).filter(|e| e.visible && is_geometry(&e.id)).collect(); + let carry = reshape_leaves_apart(&mut art, &later, &n.appearance.effects, Budget::EFFECTS, apart, doc); + let mut ran = n.appearance.effects.clone(); + if carry.skipped() { + carry.hide_skipped(&mut ran); + } + art.appearance.effects = crate::raster::without_geometry(ran.get(t..).unwrap_or_default()); + art +} + +/// The effects of `n` that paint around its whole art: all of its own, or from the Transform effect +/// on for a path whose copies each carry the raster effects listed before it ([`copied_raster_at`]). +pub fn outer_effects(n: &Node) -> &[Effect] { + match copied_raster_at(n) { + Some(t) => n.appearance.effects.get(t..).unwrap_or_default(), + None => &n.appearance.effects, + } +} + /// A group with `n`'s id holding `n` without its transparency, opacity mask and effects: the one /// piece that `n`'s own geometry effects reshape. fn one_piece(n: &Node) -> Node { @@ -899,6 +1018,9 @@ pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option<Node> { return None; } + if let Some(t) = copied_raster_at(n) { + return Some(dressed_keeping_effects(copied_raster_art(doc, n, t, false), n)); + } if n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) { // One element: its pieces never knock each other out. return crate::bake_type_appearance(doc, n).map(|art| Node { knockout: vectorcraft_doc::Knockout::Off, ..art }); diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index a610ae7c71..1ebcab785a 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -584,8 +584,9 @@ fn image(id: u64, effects: Vec<Effect>) -> Node { /// A group's Transform effect transforms, with Scale Strokes & Effects off or on, the raster /// effects that a member's own Transform effect with copies holds back: they keep their value /// through the member's effect and transform with the group's. That holds for a shadow listed -/// before the member's copying effect, one listed after it, a fill's shadow under it, the shadow -/// of a member of a nested group with such an effect, and an image's shadow. +/// before the member's copying effect (which each copy carries), one listed after it, a fill's +/// shadow under it, the shadow of a member of a nested group with such an effect, and an image's +/// shadow. #[test] fn a_groups_transform_moves_the_shadows_its_members_copies_hold_back() { let copies = || transform(json!({"moveH": 60, "copies": 1})); @@ -605,7 +606,8 @@ fn a_groups_transform_moves_the_shadows_its_members_copies_hold_back() { let art = crate::evaluate_container(None, &g).unwrap(); for id in [71, 72, 73, 75, 76] { let drawn = shadows_in(&node_in(&art, id)); - assert!(near(&drawn, &[want]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}"); + let wanted = if id == 71 { vec![want, want] } else { vec![want] }; + assert!(near(&drawn, &wanted), "Scale Strokes & Effects {strokes}: {id} {drawn:?}"); } } } @@ -841,3 +843,93 @@ fn copying_and_object_disabled_transforms_keep_their_existing_shadow_rules() { assert!(close(drawn_shadow(&effects), (10.0, 5.0, 4.0)), "{t:?}: {:?}", drawn_shadow(&effects)); } } + +/// The blur of every Drop Shadow drawn on `n` and the nodes under it, in the order of +/// [`shadows_in`]. +fn shadow_blurs_in(n: &Node) -> Vec<f64> { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + for r in raster_effects(list) { + if let RasterFx::DropShadow { blur, .. } = r { + out.push(blur); + } + } + } + }); + out +} + +/// How many paths `n` holds. +fn paths_in(n: &Node) -> usize { + let mut count = 0; + n.walk(&mut |m| count += usize::from(matches!(m.kind, vectorcraft_doc::NodeKind::Path { .. }))); + count +} + +/// A Transform effect that makes copies gives each copy the shadow listed before it, moved, turned, +/// reflected and scaled with that copy, the last copy lowest: drawn, expanded and baked. The object +/// itself keeps the shadow as it is, and paints none around all of them. With Scale Strokes & +/// Effects on, the shadow is not scaled a second time. +#[test] +fn each_copy_of_a_transform_effect_carries_the_shadow_listed_before_it() { + let cases = [ + (json!({"moveH": 100, "copies": 2}), vec![(10.0, 0.0), (10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0, 4.0]), + // A turn of 90° counterclockwise on screen takes an offset to the right to one upward. + (json!({"rotate": 90, "copies": 2}), vec![(-10.0, 0.0), (0.0, -10.0), (10.0, 0.0)], vec![4.0, 4.0, 4.0]), + (json!({"reflectX": true, "moveH": 100, "copies": 1}), vec![(-10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0]), + (json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1}), vec![(20.0, 0.0), (10.0, 0.0)], vec![8.0, 4.0]), + (json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}), vec![(20.0, 0.0), (10.0, 0.0)], vec![8.0, 4.0]), + (json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "random": true}), vec![(10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0]), + ]; + for (params, want, blurs) in cases { + let n = square(91, vec![shadow(10.0, 0.0, 4.0), transform(params.clone())]); + assert!(crate::has_transform_art(&n), "{params}"); + assert!(crate::outer_effects(&n).iter().all(|e| e.id != "stylize.dropShadow"), "{params}"); + let random = params["random"].as_bool().unwrap_or(false); + for (how, art) in + [("drawn", crate::reshape(None, &n, None).unwrap()), ("expanded", expand_outlined(None, &n, None).unwrap()), ("baked", baked(n.clone()))] + { + let drawn = shadows_in(&art); + assert_eq!(paths_in(&art), want.len(), "{params}: {how} {art:?}"); + if random { + // Random scales the copy by a share of the 200 %, and its shadow with it; the object + // keeps its own. + assert_eq!(drawn.len(), want.len(), "{params}: {how} {drawn:?}"); + assert!(near(&drawn[1..], &want[1..]), "{params}: {how} {drawn:?}"); + continue; + } + assert!(near(&drawn, &want), "{params}: {how} {drawn:?}"); + let b = shadow_blurs_in(&art); + assert!(b.len() == blurs.len() && b.iter().zip(&blurs).all(|(x, y)| (x - y).abs() < 1e-9), "{params}: {how} blurs {b:?}"); + } + } +} + +/// A shadow listed after a Transform effect that makes copies paints once around all of them, and +/// one listed before it on each copy. A Transform effect that makes no copies, or has Transform +/// Objects off, leaves the object's shadows on the object. +#[test] +fn a_shadow_after_a_copying_transform_paints_once_around_the_copies() { + let n = square(92, vec![shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90, "copies": 1})), shadow(0.0, 5.0, 0.0)]); + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(0.0, 5.0), (0.0, -10.0), (10.0, 0.0)]), "{:?}", shadows_in(&art)); + assert_eq!(crate::outer_effects(&n).len(), 2); + for params in [json!({"rotate": 90}), json!({"rotate": 90, "copies": 1, "objects": false})] { + let n = square(93, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]); + assert!(!crate::has_transform_art(&n), "{params}"); + assert_eq!(crate::outer_effects(&n).len(), 2, "{params}"); + } +} + +/// Copies that stop at the effect budget each keep their shadow: no copy goes without one, and no +/// shadow is left without its copy. +#[test] +fn copies_past_the_budget_each_keep_their_shadow() { + let n = path_node(94, wavy(30_000), vec![shadow(1.0, 0.5, 0.0), transform(json!({"moveV": 50, "copies": 1000}))]); + let art = crate::reshape(None, &n, None).unwrap(); + let paths = paths_in(&art); + assert!(paths > 1 && paths < 1001, "{paths}"); + assert_eq!(shadows_in(&art).len(), paths); +} diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index c8b51033fe..a97d414500 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -257,6 +257,27 @@ fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canva } } +/// Each copy of a Transform effect draws the drop shadow listed before the effect under itself, +/// scaled with it, and Expand Appearance keeps those shadows: the square (20..40) has its shadow +/// 20 pt to its right, and its copy scaled 200 % and moved 80 pt down (10..50 across, 90..130 +/// down) has its own 40 pt to its right. +#[test] +fn each_copys_shadow_expands_as_the_canvas_draws_it() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 20, "height": 20})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let shadow = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"}); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 200, "scaleV": 200, "moveV": 80, "copies": 1}})); + let before = render(&s); + assert_eq!(before.pixel(70, 110), [0, 255, 0, 255], "the copy's shadow"); + run(&mut s, "effect.expandAppearance", json!({})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "{diff} pixels differ"); +} + /// A group's Transform effect runs after its member's own Offset Path in Expand Appearance as on /// the canvas: the 100 pt square offset to 120 pt and scaled by half expands 60 pt wide. #[test] diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index ce76713e74..aef91df679 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -67,6 +67,13 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> { if let Some(art) = painted_art(symbols, n) { return art.visual_bounds_with(symbols).map(|r| with_raster_reach(symbols, n, r)); } + // Copies that each carry the raster effects listed before their Transform effect reach as far + // as each copy's raster effects paint, and the raster effects after it spread them all. + if effects::outer_effects(n).len() < n.appearance.effects.len() + && let Some(art) = effects::reshape(Some(symbols.document()), n, None) + { + return cull_bounds(symbols, &art); + } let bp = node_bezpath(n)?; let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) }; let (g, carry) = effected_path_carrying(symbols.document(), n, &bp); diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 190d433973..65a886dc8c 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -423,11 +423,12 @@ impl Renderer { let outer = self.made_again; self.made_again = outer || !kept; // With Crop Marks, the object inside the art paints its own raster effects; Vector - // Halftone samples them with the object. + // Halftone samples them with the object. The copies of a Transform effect that carry the + // raster effects listed before it paint those themselves. let rfx = if effects::has_crop_marks(a) || effects::addons::halftone::has_halftone(a) { vec![] } else { - effects::raster_effects(&a.appearance.effects) + effects::raster_effects(effects::outer_effects(a)) }; match cull_bounds(f.symbols, &art) { Some(reach) if !f.opts.outline && !rfx.is_empty() => { diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index c359f65219..f93124e1b9 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -1219,3 +1219,26 @@ fn a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn() { assert!(within(b), "{what}: painted {b:?} for art {art:?}"); } } + +/// Each copy of a Transform effect draws the drop shadow listed before the effect under itself, +/// scaled with it: the red square (10..30) and its copy scaled 200 % and moved 60 pt down (0..40 +/// across, 60..100 down) each have a green shadow to their right, 20 pt for the square and 40 pt +/// for the copy. The bounds reach as far as the copy's shadow. +#[test] +fn each_copy_of_a_transform_effect_draws_its_own_shadow() { + let shadow = fx("stylize.dropShadow", json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"})); + let copies = fx("distort.transform", json!({"scaleH": 200, "scaleV": 200, "moveV": 60, "copies": 1})); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), red); + n.appearance.effects = vec![shadow, copies]; + let d = doc_with(Document::new(100.0, 100.0), n); + let img = render(&d); + assert_eq!(img.pixel(20, 20), [255, 0, 0, 255], "the square"); + assert_eq!(img.pixel(45, 20), [0, 255, 0, 255], "the square's shadow"); + assert_eq!(img.pixel(20, 80), [255, 0, 0, 255], "the copy"); + assert_eq!(img.pixel(70, 80), [0, 255, 0, 255], "the copy's shadow, 40 pt to its right"); + assert_eq!(img.pixel(85, 80), [255, 255, 255, 255], "past the copy's shadow"); + let n = &d.layers[0].children().unwrap()[0]; + let b = crate::fx::visual_bounds(&d.symbol_boxes(), n).unwrap(); + assert!(b.x1 >= 80.0 - 1e-9, "{b:?}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad7e9f4eab..d4ea26057e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -616,6 +616,7 @@ since 2026-10-01. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged. +- **Raster effects before a Transform effect that makes copies:** the Transform effect can make copies with its transformation applied ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)). Giving each copy the raster effects listed before that effect is VectorCraft's own rule. On a path or compound path, each copy now carries the Drop Shadow, glow, blur, Feather or pixel filter listed before the last Transform effect that makes copies, with its sizes scaled by that copy's mean scale and a Drop Shadow's offset turned, reflected and scaled with the copy; a raster effect listed after it paints once around all the copies. Before, one shadow was drawn under all the copies at the object's own distances. The canvas, the object's bounds, export and Expand Appearance draw the same copies, and copies that stop at the effect budget each keep their effects (effects `tests::rasterfollow::each_copy_of_a_transform_effect_carries_the_shadow_listed_before_it`, `tests::rasterfollow::a_shadow_after_a_copying_transform_paints_once_around_the_copies`, `tests::rasterfollow::copies_past_the_budget_each_keep_their_shadow`; render `objectfx::each_copy_of_a_transform_effect_draws_its_own_shadow`; engine `objexpand::each_copys_shadow_expands_as_the_canvas_draws_it`). A Transform effect without copies draws as before. Not yet: the copies of type, images, symbol instances and groups, and a fill's or stroke's own copying Transform effect, still share one set of raster effects, and so does a path with Rasterize, an SVG filter or another effect that is neither a geometry effect nor one of those raster effects before the copying effect. Scores and hours unchanged. - **Raster effects before a Transform effect past the effect budget:** VectorCraft leaves out a Transform effect whose result would pass its geometry budget of 2 million anchors or 8 million lines drawn, and the art stays where it was. The raster effects listed before that Transform effect now stay with the art too: a Drop Shadow keeps its own offset and blur, and the Transform effects after it that fit still move both. Before, the art stayed while the shadow scaled with the Transform effect it did not take. This is VectorCraft's own rule. It applies to a path's own effects and to its fills' and strokes' own, also when the path is in a group whose geometry effects run after its members' own, on the canvas and in its bounds, in export, Expand Appearance, Object › Expand and Outline Stroke, and a hidden shadow keeps its values once shown (effects `tests::rasterfollow::a_transform_past_the_line_budget_moves_the_art_with_its_shadow`, `tests::rasterfollow::only_transforms_that_ran_move_a_preceding_shadow`, `tests::rasterfollow::a_skipped_item_transform_keeps_its_shadow`, `tests::rasterfollow::the_shape_art_of_a_skipped_transform_keeps_its_shadows`, `tests::rasterfollow::a_members_skipped_transform_keeps_its_shadow_under_its_groups_effects`; render `objectfx::a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn`, `fx::renderer::tests::a_path_paints_from_the_transforms_that_moved_it`; engine `objexpand::a_shadow_before_a_skipped_transform_expands_with_its_own_offset`). Not yet: the shadow of type, an image, a symbol instance or a group whose art a Transform effect leaves in place past the budget still follows that effect. Scores and hours unchanged. - **Raster effects inside an object or group with a Transform effect:** effects applied to a group affect everything in it ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). How a Transform effect changes the raster effects inside a group or object is VectorCraft's own rule. A group's Transform effect that moves it once now transforms the raster effects of its members, of their fills and strokes, of nested groups and of the art of the linked opacity masks in it, after the members' own Transform effects. A raster effect that a member's own Transform effect with copies or random holds back keeps its value through that effect and still transforms with the group's. VectorCraft applies the same rule to the raster effects of an object's fills and strokes under the object's Transform effect. Sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, with Scale Strokes & Effects on or off. Before, they kept their values, or with Scale Strokes & Effects on scaled by the mean scale with the offset unturned. A group's or object's Transform effect that makes copies or moves at random leaves them as before. The canvas, the redraw region, export, Expand Appearance, Object › Expand and Outline Stroke use the same values (effects `tests::rasterfollow`, `group::tests::a_compound_shapes_transform_turns_its_fills_shadow`; render `a_transform_scales_the_shadows_of_fills_and_members_with_the_art`; engine `objexpand::a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it`, `objexpand::shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them`, `outlinestroke::a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it`). Scores and hours unchanged. - **A group's geometry effects after its members' own:** effects applied to a group affect everything in it, and effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). Running a group's effects after its members' own is VectorCraft's own rule. The geometry effects of a group or layer (the effects that reshape outlines, such as Distort & Transform, Offset Path and Warp) now reshape what its members draw: the geometry effects of each visible path and compound path in it and of their fills and strokes, and a nested group's own geometry and Pathfinder effects, run first, at any depth. A symbol instance's own geometry effects run after those of its symbol's members in the same way. Before, the group's effects reshaped the members' outlines and the members' own effects ran afterwards: a 100 pt square with its own 10 pt Offset Path in a group with a 50% Transform drew 70 pt wide, and it now draws 60 pt wide. Existing documents with such groups draw differently. The canvas, export, Expand Appearance and the art that Pathfinder operations and compound shapes take from such a group use the same order (effects `group::tests::a_members_own_offset_runs_before_its_groups_scale`, `group::tests::a_members_own_scale_runs_before_its_groups_offset`, `group::tests::a_member_of_a_plain_nested_group_takes_its_own_effects_first`, `group::tests::a_nested_groups_own_effects_run_before_its_groups`, `group::tests::a_nested_groups_pathfinder_runs_before_its_groups_effects`, `group::tests::a_members_fill_effects_run_before_its_groups`, `group::tests::shape_art_of_a_group_applies_its_members_effects_first`, `group::tests::a_symbol_instances_effects_run_after_its_symbols_members_own`, `group::tests::a_baked_group_applies_its_members_effects_first`, `group::tests::a_baked_group_that_keeps_its_type_applies_its_members_effects_first`, `tests::rasterfollow::a_groups_transform_applies_after_its_members_own`; engine `objexpand::a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them`). Not yet: the own geometry effects of images, symbol instances, paths with a linked opacity mask or a 3D effect, and nested groups with a 3D effect in the group still run after the group's. Scores and hours unchanged. From e464b612cf1da46de0d24d84bdc8c1a9f0e75f34 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:38:36 -0800 Subject: [PATCH 55/89] Paste: a copied file over 64 MiB is skipped before it is read Paste read a file copied in a file manager whole with std::fs::read, with no size limit. It now checks the opened file's size against MAX_BYTES (64 MiB, the limit the Wayland clipboard reads already use, now shared) before reading its first bytes, and reads the rest with a bounded reader, so a file that grows after the check is read to at most one byte past the limit and skipped. A skipped file lets the next copied file that is art paste, as an unreadable one does. --- apps/vectorcraft/src/clipboard.rs | 83 ++++++++++++++++++++++++++++--- docs/target-app-parity.md | 1 + 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 17b9327956..48147617a6 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -17,7 +17,8 @@ //! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at //! once; a paste then waits for that read's answer off the UI thread. //! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS, -//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). +//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). A +//! file of more than 64 MiB is skipped before it is read. use std::io::{Cursor, Read}; use std::path::PathBuf; @@ -144,9 +145,18 @@ fn dib_image(dib: &[u8]) -> Option<image::RgbaImage> { Some(img) } +/// The most bytes a paste takes from another app (untrusted data), VectorCraft's own limit: a copied +/// file of more is skipped, and on Wayland a read of more fails. +const MAX_BYTES: usize = 64 << 20; + /// The first of `paths` (files copied in a file manager) that pastes as one of `mimes`, read -/// whole. Only its first bytes are read to tell, so other files cost little. +/// whole, at most [`MAX_BYTES`]. Only its first bytes are read to tell, so other files cost little. fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> { + copied_file_within(paths, mimes, MAX_BYTES) +} + +/// [`copied_file`] with a file of more than `limit` bytes skipped. +fn copied_file_within(paths: Vec<PathBuf>, mimes: &[&'static str], limit: usize) -> Option<Flavour> { paths.into_iter().find_map(|path| { // text/uri-list lines end in CRLF (RFC 2483), but arboard splits them on LF only. let path = match path.to_str() { @@ -164,14 +174,29 @@ fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> { if !std::fs::metadata(&path).ok()?.is_file() { return None; } + let mut file = std::fs::File::open(&path).ok()?; + // The file opened may not be the one looked at: its size is checked before any read. + let meta = file.metadata().ok()?; + if !meta.is_file() || meta.len() > limit as u64 { + return None; + } let mut head = Vec::with_capacity(FILE_HEAD); - std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?; + (&mut file).take(FILE_HEAD as u64).read_to_end(&mut head).ok()?; let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?; - let data = std::fs::read(&path).ok()?; + let data = read_rest(file, head, limit)?; (!data.is_empty()).then_some(Flavour { mime, data }) }) } +/// `data` (the first bytes read of `file`) followed by the rest of `file`, or `None` when they come +/// to more than `limit` bytes, as when the file grew after its size was checked. At most one byte +/// past `limit` is read. +fn read_rest(file: impl Read, mut data: Vec<u8>, limit: usize) -> Option<Vec<u8>> { + let more = (limit + 1).saturating_sub(data.len()); + file.take(more as u64).read_to_end(&mut data).ok()?; + (data.len() <= limit).then_some(data) +} + #[cfg(windows)] mod win { use std::num::NonZeroU32; @@ -1817,14 +1842,11 @@ mod wayland { use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum}; use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer}; - use super::{BITMAP, Flavour, PNG, SVG, TEXT}; + use super::{BITMAP, Flavour, MAX_BYTES, PNG, SVG, TEXT}; /// The types text is offered under and read from, best first: `text/plain;charset=utf-8` (GTK /// and Qt apps), `UTF8_STRING` (X11 apps through XWayland) and `text/plain`. const TEXT_TYPES: [&str; 3] = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"]; - /// The most bytes a read takes from the app that copied (untrusted data), VectorCraft's own - /// limit: a read of more fails. - const MAX_BYTES: usize = 64 << 20; /// The most pastes of a Copy's data that are sent at once; a paste beyond them gets nothing. const MAX_SENDS: usize = 8; /// The most types of an offer that are kept (untrusted data). @@ -2952,6 +2974,51 @@ mod tests { assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG)); } + /// A copied file of more than 64 MiB is skipped before it is read, and the next one pastes. + #[test] + fn an_oversized_copied_file_is_skipped_before_it_is_read() { + let d = dir("oversized"); + let (big, small) = (d.path().join("big.png"), d.path().join("small.png")); + std::fs::write(&big, png(2, 2)).unwrap(); + // Sparse where the file system allows: no 64 MiB is written. + std::fs::OpenOptions::new().write(true).open(&big).unwrap().set_len(MAX_BYTES as u64 + 1).unwrap(); + std::fs::write(&small, png(3, 2)).unwrap(); + assert!(copied_file(vec![big.clone()], &PASTE_ORDER).is_none()); + let f = copied_file(vec![big, small], &PASTE_ORDER).unwrap(); + assert_eq!((f.mime, f.data), (PNG, png(3, 2))); + } + + /// A copied file of exactly the limit pastes; one byte more and it is skipped. + #[test] + fn a_copied_file_pastes_up_to_the_limit() { + let d = dir("limit"); + let art = d.path().join("art.svg"); + let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"/>"; + std::fs::write(&art, svg).unwrap(); + let read = |limit| copied_file_within(vec![art.clone()], &PASTE_ORDER, limit).map(|f| f.data); + assert_eq!(read(svg.len()), Some(svg.as_bytes().to_vec())); + assert_eq!(read(svg.len() - 1), None); + } + + /// A file that grows past the limit after its size was checked is read to one byte past the + /// limit and refused. + #[test] + fn the_rest_of_a_file_is_read_to_the_limit_at_most() { + let read = |len: usize, head: usize| { + let bytes = vec![7u8; len]; + let mut file = Cursor::new(bytes); + let mut first = vec![0; head]; + file.read_exact(&mut first).unwrap(); + (read_rest(&mut file, first, 16).map(|d| d.len()), file.position()) + }; + assert_eq!(read(16, 4), (Some(16), 16)); + assert_eq!(read(0, 0), (Some(0), 0)); + assert_eq!(read(17, 4), (None, 17)); + assert_eq!(read(1000, 4), (None, 17)); + // First bytes already past the limit: nothing more is read. + assert_eq!(read(1000, 20), (None, 20)); + } + /// A DIB of `w`×`h` pixels (top row first when `h` is negative): a `header`-byte header (40, /// or 124 for a BITMAPV5HEADER with BGRA masks), `extra` (the masks after a 40-byte header, a /// colour table), then `rows`, each padded to 4 bytes. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad7e9f4eab..8b87a5f514 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1810,6 +1810,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). - A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes the objects of its editing data, and one saved as PostScript pastes as a copied `.eps` file does. + - Paste checks the size of a file copied in a file manager before reading it: a file of more than 64 MiB, VectorCraft's own limit, is skipped and the next copied file that is art pastes. A file that grows past the limit while it is read is skipped too, after at most one byte more than the limit is read. - A copied `.ai` or `.ait` file that holds a PDF pastes the objects of its editing data as File › Open reads them: every layer's objects go into the active layer in their stacking order, with their names, hidden objects and type objects, instead of the PDF page's art with its type as outlines. When the editing data can't be read, the file pastes its page as before (`clipboard.importPdf {editingData}`). The objects of a hidden layer come in hidden, from a pasted EPS too. - Import and export, later 2026-10-13 merges: SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); the warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); the pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); the ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); a zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). - Files and untrusted input, 2026-10-14 merges: EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). From a3d468b226a4f78296e4365ff95324d5d01006a7 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:33:31 -0800 Subject: [PATCH 56/89] Docs: the localization page lists Indonesian once and marks Bosnian by what it translates Indonesian had a row in both tables with different counts. Keep the row in the key-language table and give it the counts on main at a47d7eec91. Bosnian was described as having complete messages, but `bs` is not in COMPLETE_MESSAGES. Its row now gives its counts and the menu, `tl!` and undo gaps the untranslated report lists, and the measurement note names Bosnian with the other languages outside COMPLETE_MESSAGES. --- docs/localization-parity.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d62b475105..8c486711f7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -15,7 +15,7 @@ translation never fails a test or a build: the interface shows the English text `cargo xtask ci` prints the untranslated report (`cargo xtask i18n-report` alone), which lists per language the strings missing from each catalog with the `complete_menus` flag and the messages missing from each language in the `COMPLETE_MESSAGES` list. Every non-English language marked "full" has the flag, and all of them except -Bulgarian, Danish and Indonesian are in the list. No language has help content: there is no translated (or +Bulgarian, Bosnian, Danish and Indonesian are in the list. No language has help content: there is no translated (or English) in-app help, and the docs are English. **Native review:** every catalog was written clean-room from the English meaning, most of it by agents @@ -36,7 +36,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 9,166 rows, 3,436 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 178 missing messages for it | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 9,260 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 39 missing messages for it; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | -| Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | +| Indonesian | `id` | 6,819 rows, 1,033 of them `@msg` and 47 `@msgplural` (counted 2026-11-14 on `main` at `a47d7eec91`): the untranslated report lists 59 missing menu strings, 35 `tl!` literals and 13 undo labels for it; not in `COMPLETE_MESSAGES`, and checked against the same scanned messages it lacks 2,501; one plural form | most dialogs and tooltips; status and error messages partial | Latin | no | full | — (review) | | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,563 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 52 missing messages for it, which open #7116, #7211 and #7378 translate) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all (3,438 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 39 missing messages for it) | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | @@ -62,13 +62,12 @@ plus review. Native-speaker review is human work and isn't in the hours. | Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending | | Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending | | Czech | `cs` | 9,155 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it | catalog complete; render-site gaps reduced; native review pending | -| Bosnian | `bs` | 5,841 rows (counted 2026-10-30): menus, `tl!` literals and messages complete when counted; one/few/other plurals | full; AI-assisted, native review pending | +| Bosnian | `bs` | 6,881 rows, 1,141 of them `@msg` and 47 `@msgplural` (counted 2026-11-14 on `main` at `a47d7eec91`): the untranslated report lists 96 missing menu strings, 53 `tl!` literals and 27 undo labels for it; not in `COMPLETE_MESSAGES`, and checked against the same scanned messages it lacks 2,409; one/few/other plurals | full; AI-assisted, native review pending | | Swedish | `sv` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals | full; native review welcome | | Bulgarian | `bg` | 5,245 rows, 763 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial | full; AI-drafted, native review pending | | Greek | `el` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | full; native review welcome | | Georgian | `ka` | 5,200 rows (counted 2026-10-21): menus, panels, dialogs, plurals (one count-neutral form) and messages, tested complete; reviewed by a native speaker | full | | Turkish | `tr` | 5,558 rows, 880 of them `@msg` (counted 2026-10-28); 6 menu strings and the default swatch names still fall back to English | full | -| Indonesian | `id` | 5,531 rows, 788 of them `@msg` (counted 2026-10-28): menus and `tl!` literals complete when counted; its messages are partial; one plural form | full | | Catalan | `ca` | 5,626 rows, 881 of them `@msg` (counted 2026-10-28): menus, `tl!` literals and messages complete when counted; one/other plurals; every `ca-*` locale resolves here | full; AI-drafted, native review welcome | Twenty-seven non-English catalogs in all (registry count, 2026-11-06). Illustrator, as its public documentation lists, ships around two dozen From 648aedbe37745b99a8ef0e232a5bd88c2d1cc26a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:45:32 -0800 Subject: [PATCH 57/89] Type: a COLR glyph whose radial gradient draws no circle draws nothing A radial gradient whose circles both have the same negative radius (as a variable font's instance can give) draws no circle. A glyph painted with only that gradient was read as no colour glyph, so layout drew its outline instead. It is now colour art that draws nothing. --- crates/text/src/colr.rs | 12 ++++++++++-- crates/text/src/test_fonts.rs | 6 ++++++ crates/text/src/tests/colr.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs index 80aa3132cf..281ef8a65f 100644 --- a/crates/text/src/colr.rs +++ b/crates/text/src/colr.rs @@ -148,6 +148,9 @@ struct Tree<'a> { painted: Option<Rect>, /// It paints with the foreground colour (palette index 0xFFFF). foreground_used: bool, + /// A fill draws nothing (a radial gradient none of whose circles has a positive radius), so a + /// glyph with nothing else is colour art that draws nothing, not its outline. + drew_nothing: bool, } impl<'a> Tree<'a> { @@ -166,6 +169,7 @@ impl<'a> Tree<'a> { too_large: false, painted: None, foreground_used: false, + drew_nothing: false, } } @@ -254,7 +258,7 @@ impl<'a> Tree<'a> { self.close(); } let root = self.groups.pop()?; - if root.children.is_empty() { + if root.children.is_empty() && !self.drew_nothing { return None; } Some(Arc::new(Node::group(NO_ID, root.children))) @@ -314,7 +318,11 @@ impl<'a> Tree<'a> { self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette"); return None; } - let (c0, r0, c1, r1, mut stops) = non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), line, *extend)?; + let Some((c0, r0, c1, r1, mut stops)) = non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), line, *extend) + else { + self.drew_nothing = true; + return None; + }; let concentric = (c0 - c1).hypot() < 1e-6; if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 { self.unsupported.get_or_insert("a radial gradient from a circle"); diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index bdb818d3f3..7abae3f0eb 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1939,6 +1939,12 @@ pub fn colr_variable_font() -> Option<Vec<u8>> { with_colr(&variable_font()?, family_names(COLR_VARIABLE_FAMILY, &axis_names)?, &colr) } +/// [`variable_font`] (one weight axis, 400 to 700) as `family` with `colr` and [`colr_font`]'s +/// palette. `None` when it can't be built. +pub fn colr_variable_font_with(family: &str, colr: &write_fonts::tables::colr::Colr) -> Option<Vec<u8>> { + with_colr(&variable_font()?, family_names(family, &[])?, colr) +} + /// A `name` table for Regular of `family`, with `more` records. fn family_names(family: &str, more: &[(u16, &str)]) -> Option<Vec<u8>> { let ps = family.replace(' ', ""); diff --git a/crates/text/src/tests/colr.rs b/crates/text/src/tests/colr.rs index 26e5fd4e6a..c6c4696f16 100644 --- a/crates/text/src/tests/colr.rs +++ b/crates/text/src/tests/colr.rs @@ -467,6 +467,38 @@ fn an_image_of_a_radial_gradient_with_a_stop_past_its_circle_of_radius_0_draws_f } } +/// A radial gradient whose circles both have the same negative radius at a variable font's +/// instance draws no circle, so a glyph painted with only that gradient is colour art that draws +/// nothing, not its outline. The radii are 10 at the default weight and -10 at 700. +#[test] +fn a_radial_gradient_with_no_circle_drawn_leaves_the_glyph_empty() { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Extend, VarColorLine, VarColorStop}; + use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList}; + use write_fonts::types::{F2Dot14, FWord, GlyphId16, UfWord}; + let bytes = colr_font(false).unwrap(); + let read = skrifa::FontRef::new(&bytes).unwrap(); + let gid = |ch| GlyphId16::new(u16::try_from(read.charmap().map(ch).unwrap().to_u32()).unwrap()); + let (one, fixed) = (F2Dot14::from_f32(1.0), 0xFFFF_FFFF); + let line = + VarColorLine::new(Extend::Pad, 2, vec![VarColorStop::new(F2Dot14::from_f32(0.0), 0, one, fixed), VarColorStop::new(one, 1, one, fixed)]); + let w = FWord::new; + let radial = ColrPaint::var_radial_gradient(line, w(290), w(330), UfWord::new(10), w(290), w(330), UfWord::new(10), 0); + let mut colr = Colr::new(0, None, None, 0); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), ColrPaint::glyph(radial, gid('O')))])).into(); + // One 16-bit delta for each of x0, y0, r0, x1, y1 and r1, at full weight. + let deltas: Vec<u8> = [0i16, 0, -20, 0, 0, -20].iter().flat_map(|d| d.to_be_bytes()).collect(); + let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), one, one)]); + let data = ItemVariationData::new(6, 1, vec![0], deltas); + colr.item_variation_store = Some(ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)])).into(); + let font = crate::test_fonts::colr_variable_font_with("Colrtest No Circle", &colr).unwrap(); + let font = skrifa::FontRef::new(&font).unwrap(); + let bold = font.axes().location([("wght", 700.0)]); + let Some(ColourContent::Art(root)) = colour_glyph(&font, (&bold).into(), 1000.0, u32::from(gid(COLOUR_CHAR).to_u16()), FOREGROUND).0 else { + panic!("colour art") + }; + assert!(children(&root).is_empty(), "{:?}", children(&root).iter().map(|c| c.kind_label()).collect::<Vec<_>>()); +} + /// A COLRv1 color line through `stops` (offset, palette index) that extends by `extend`. fn color_line(extend: write_fonts::tables::colr::Extend, stops: &[(f32, u16)]) -> write_fonts::tables::colr::ColorLine { use write_fonts::tables::colr::{ColorLine, ColorStop}; From e599fcb7380a84422a74d5114ea28d17c10f28e9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:51:34 -0800 Subject: [PATCH 58/89] Docs: Hindi pages state the untranslated report's Hindi gaps The Hindi localization page said the untranslated report has no Hindi section, and the language table said Hindi has no gaps in it. On main f793044f83 the report lists 608 missing Hindi rows (28 menu strings, 30 tl! strings, 11 undo labels and 539 messages) and 68 more that every checked language lacks (10 menu strings, 2 undo labels and 56 messages). Both pages now give these counts, and the table's remaining work for Hindi includes the reported rows. --- docs/localization-hi.md | 8 ++++++-- docs/localization-parity.md | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/localization-hi.md b/docs/localization-hi.md index 3d304a5d50..d73e78676b 100644 --- a/docs/localization-hi.md +++ b/docs/localization-hi.md @@ -98,8 +98,12 @@ main added paragraph labels, spine-index validation and two error messages: six Main's workspace version is still 0.21.0; this PR does not change release versioning. `cargo test -p vectorcraft-ui-egui --lib i18n::tests` passes all 55 tests in Ubuntu 24.04 (WSL). -The untranslated report has no Hindi section, meaning it found no missing Hindi rows among the -categories it checks. This does not check dynamically assembled strings that the scanners miss. +On 2026-11-14 the untranslated report on main (`f793044f83`) has a Hindi section with 608 +missing rows: 28 menu strings, 30 `tl!` strings, 11 undo labels and 539 messages. Its +`every language` section lists 68 more that every checked language lacks, Hindi included: 10 menu +strings, 2 undo labels and 56 messages. These include labels for newer features and command +parameter, script and file import or export errors, which show in English until a catalog row +is added. The report does not check dynamically assembled strings that the scanners miss. Additional checks compare placeholders, backtick-delimited identifiers and numeric values. The native Hindi UI fallback test also passes with the installed Nirmala UI font. A running desktop preview was checked in Hindi, including the New Document dialog and File menu. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d62b475105..07c12c4ec3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -31,7 +31,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | English | `en` | source, 100% | all | Latin | — | full | — | | Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all (3,554 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 55 missing messages for it, which open #7116, #7211, #7330, #7378 and #7405 translate) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | -| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font | +| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; the untranslated report on main `f793044f83` (2026-11-14) lists 676 missing rows for it: 608 in its own section and 68 that every checked language lacks | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | reported missing rows, native review, layout and web font | | Gujarati | `gu` | 4,673 rows (2026-11-14): original 4,286 unchanged; all 387 additions contributor-reviewed and approved | menus and dialogs; status/error messages partial | installed Gujarati fonts on desktop; optional `Gujr` craft-fonts fallback; no Gujarati face in the pinned CI/release fonts or web startup list | original catalog reviewed by a native speaker; all 387 later additions approved by the contributor, with 11 wording corrections | contributor-reviewed catalog | remaining messages and a bundled Gujarati web font | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 9,166 rows, 3,436 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 178 missing messages for it | all | Latin | no | full | — (review) | @@ -44,8 +44,8 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a **Historical 12-language score: ~82%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 100 + 100 + 100 + 98 + 100 + 100, over 12). **To `full` for all twelve: ~50–85 h** (historical estimate, not re-scored here). Hindi now has -a complete catalog and UI shaping; its remaining work is native review, layout checking and the -web font. Arabic still needs its catalog and right-to-left interface. Each new Latin-script catalog is ~4,000 rows; the existing ones were written in a few agent hours each +a catalog that the tests count as complete, and UI shaping; its remaining work is the rows the +untranslated report lists for it, native review, layout checking and the web font. Arabic still needs its catalog and right-to-left interface. Each new Latin-script catalog is ~4,000 rows; the existing ones were written in a few agent hours each plus review. Native-speaker review is human work and isn't in the hours. ## Other shipped languages From 38a2019f8f9fefdc9afc2075891e5528c1c1fb9e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:51:39 -0800 Subject: [PATCH 59/89] Color: format the mesh-point opacity test as rustfmt lays it out --- crates/ui-egui/src/panels/transparency.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 4f23266e97..4279ac11e4 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -380,7 +380,14 @@ mod tests { assert_eq!(picked(&app), Some(vec![0]), "the click picks the top-left point"); assert_eq!(state(&app, &ctx).0.opacity, Some(30.0), "the newly picked point's opacity"); // An active Appearance row takes the opacity, so the values are the object's again. - let rows = app.session.active().unwrap().selection.objects.first().map(|id| app.session.active().unwrap().doc.node(*id).unwrap().appearance.items.len()); + let rows = app + .session + .active() + .unwrap() + .selection + .objects + .first() + .map(|id| app.session.active().unwrap().doc.node(*id).unwrap().appearance.items.len()); assert!(rows.is_some_and(|n| n > 0), "the mesh has its stroke row"); run(&mut app, "appearance.setActiveItem", json!({"index": 0})); assert_eq!(picked(&app), None); From 11225df1182db5f288de284703c9babcc53882a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 05:55:44 -0800 Subject: [PATCH 60/89] Type: tests expect the Single-line Composer to break mixed-size overflow lines as the Every-line Composer does --- crates/text/src/tests/combos.rs | 12 ++++++++---- crates/text/src/thread/tests.rs | 7 ++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/text/src/tests/combos.rs b/crates/text/src/tests/combos.rs index ccb89b52f8..cdb5a31fee 100644 --- a/crates/text/src/tests/combos.rs +++ b/crates/text/src/tests/combos.rs @@ -501,8 +501,8 @@ fn composer_x_mixed_sizes_overflow_a_shaped_frame() { // A stepped frame too short for the paragraph: the every-line composer still breaks the lines // that fit as one paragraph, with the lines past the frame's end measured as wide as the last // line the frame holds. Here that is the narrow part, so the lines that fit break as in a frame - // tall enough for the whole paragraph, and not as the single-line composer breaks them. They - // are set in order, each once and within its room, and the frame overflows. + // tall enough for the whole paragraph. They are set in order, each once and within its room, + // and the frame overflows. let w = x_width(); let set = |bottom: f64, composer: Composer| { let mut t = stepped(w, w * 0.5, 40.0, bottom); @@ -520,8 +520,10 @@ fn composer_x_mixed_sizes_overflow_a_shaped_frame() { assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]); let (whole, ..) = set(300.0, Composer::EveryLine); assert_eq!(every, whole[..every.len()]); + // The single-line composer measures each line across its height too, and breaks the lines + // that fit the same way. let (greedy, ..) = set(75.0, Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); // A frame with no room for a line sets none. for composer in [Composer::EveryLine, Composer::SingleLine] { let mut t = stepped(w, w * 0.5, 4.0, 8.0); @@ -562,8 +564,10 @@ fn composer_x_mixed_sizes_overflow_around_text_wrap() { assert_eq!(hits.count(), 0, "{:?}", l.lines); // The lines that fit break as in `composer_x_mixed_sizes_around_text_wrap`'s taller frame. assert_eq!(every, ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature"]); + // The single-line composer measures each line across its height too, and breaks the lines + // that fit the same way. let (greedy, ..) = set(Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); } #[test] diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index adbf3b2c70..4ea7696aae 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -415,8 +415,7 @@ fn stepped_frame(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject { /// A paragraph of mixed type sizes that a stepped frame cannot hold goes on in the next frame. The /// every-line composer breaks the lines the first frame holds as one paragraph, as in a frame tall -/// enough for all of it and not as the single-line composer breaks them, and the first frame -/// takes the text of those lines. The next frame sets the rest at its own width, without the +/// enough for all of it, and the first frame takes the text of those lines. The next frame sets the rest at its own width, without the /// first-line indent, and no character is lost or repeated. The frames take the same shares as /// when each is measured with the whole rest of the story. #[test] @@ -460,8 +459,10 @@ fn a_mixed_size_paragraph_continued_from_a_shaped_frame_is_composed_in_both() { assert_eq!(every, whole[..every.len()], "indent {indent}"); if indent == 0.0 { assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]); + // The single-line composer measures each line across its height too, and breaks the + // first frame's lines the same way. let (greedy, ..) = shares(Composer::SingleLine, indent); - assert_eq!(greedy, ["Destroy all creatures with toughness X", "or less. Then draw a", "card for each", "creature destroyed"]); + assert_eq!(greedy, every); } // The next frame starts at its left edge, and its lines keep within it. assert!((line_start(&lays[1], 0) - 200.0).abs() < 1e-6, "{}", line_start(&lays[1], 0)); From e9c26abe4d8377eb16683955f06180ad01f4a76f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 06:11:07 -0800 Subject: [PATCH 61/89] Flatten: held ruler guides are gathered in one walk When a flattened root is a sublayer, its ruler guides were found by checking whether the sublayer held any and then walking its whole subtree again to collect them. They are now collected in one descent through the sublayer and its sublayers, looking at each of their children once. The guides, their order and the layer each one goes to are unchanged. A test-only record of the nodes looked at checks that each one is looked at once and that the guides keep their paint order. --- crates/engine/src/cmd/flatten/apply.rs | 86 +++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index ef8425b362..b159fa7361 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -797,22 +797,47 @@ fn place_of(d: &Document, roots: &[NodeId]) -> Option<NodeId> { } /// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer -/// that holds it, in order. Other objects hold none. +/// that holds it, in order. Other objects hold none. Each root, and each child of it and of its +/// sublayers, is looked at once. fn held_guides(d: &Document, roots: impl IntoIterator<Item = NodeId>) -> Vec<(NodeId, Node)> { let mut out = vec![]; for id in roots { let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue }; - if n.holds_ruler_guides() { - n.walk(&mut |c| { - if c.is_ruler_guide() { - out.push((parent, c.clone())); - } - }); + guide_seen(n); + if n.is_layer() { + guides_of(n, parent, &mut out); } } out } +/// Push each ruler guide of `layer` and of its sublayers onto `out` in paint order, with `parent`. +fn guides_of(layer: &Node, parent: NodeId, out: &mut Vec<(NodeId, Node)>) { + for c in layer.children().into_iter().flatten() { + guide_seen(c); + if c.is_ruler_guide() { + out.push((parent, Node::clone(c))); + } else if c.is_layer() { + guides_of(c, parent, out); + } + } +} + +#[cfg(test)] +thread_local! { + /// The nodes that [`held_guides`] has looked at on this thread, in order. + static GUIDES_SEEN: std::cell::RefCell<Vec<NodeId>> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Note that [`held_guides`] looked at `n`. +#[cfg(test)] +fn guide_seen(n: &Node) { + GUIDES_SEEN.with(|s| s.borrow_mut().push(n.id)); +} + +#[cfg(not(test))] +fn guide_seen(_: &Node) {} + /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); @@ -922,6 +947,53 @@ mod tests { assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); } + /// The guides of a sublayer and of its sublayers are gathered in paint order with the layer + /// that holds it, looking at the sublayer and each child of it and of its sublayers once. + /// Other roots give none. + #[test] + fn held_guides_look_at_each_node_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sublayer = + |s: &mut Session, parent: NodeId| NodeId(s.execute("layer.newSublayer", &json!({"parent": parent.0})).unwrap()["id"].as_u64().unwrap()); + let rect = |s: &mut Session, on: NodeId| { + s.execute("layer.setCurrent", &json!({"id": on.0})).unwrap(); + NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()) + }; + let sub = sublayer(&mut s, layer); + let deep = sublayer(&mut s, sub); + let empty = sublayer(&mut s, layer); + let (a, b, c) = (rect(&mut s, sub), rect(&mut s, deep), rect(&mut s, empty)); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let mut guide = |on: NodeId, pos: f64| { + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + d.insert(Some(on), usize::MAX, g).unwrap() + }; + let (g1, g2, g3) = (guide(sub, 1.0), guide(deep, 2.0), guide(sub, 3.0)); + let doc = &s.doc().unwrap().doc; + let ids = |n: &Node| { + let mut v = vec![]; + n.walk(&mut |c| v.push(c.id)); + v + }; + assert_eq!(ids(doc.node(sub).unwrap()), vec![sub, deep, b, g2, a, g1, g3]); + let run = |roots: &[NodeId]| { + GUIDES_SEEN.with(|v| v.borrow_mut().clear()); + let got: Vec<_> = held_guides(doc, roots.iter().copied()).into_iter().map(|(p, g)| (p, g.id)).collect(); + (got, GUIDES_SEEN.with(|v| v.take())) + }; + // Every node of the sublayer is looked at once, and the guides keep their paint order. + assert_eq!(run(&[sub]), (vec![(layer, g2), (layer, g1), (layer, g3)], vec![sub, deep, b, g2, a, g1, g3])); + // A sublayer that holds none gives none; a path holds none; a missing root and a top-level + // layer, which has no parent, are skipped. + assert_eq!(run(&[empty]), (vec![], vec![empty, c])); + assert_eq!(run(&[a]), (vec![], vec![a])); + assert_eq!(run(&[NodeId(u64::MAX), layer]), (vec![], vec![])); + // A deeper sublayer gives its guides to the sublayer that holds it. + assert_eq!(run(&[deep, deep]).0, vec![(sub, g2), (sub, g2)]); + } + /// In a file without transparency with Clip Complex Regions on, art under a group's opaque /// image within one of its pixels joins the group, also where a long image is made at a lower /// resolution and its pixels are wider than at the preset's. From 7756b1c815569080ea60f3eb63b02637672162f3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 07:30:58 -0800 Subject: [PATCH 62/89] Image Trace: quantize runs its passes over every pixel on all cores Finding the blend pixels and settling them onto a side took about 85% of color quantize on a 16 MP raster, all of it on one thread. The k-means palette step works on at most 32,768 histogram bins and took under 1%. - own_colours_among and settled work through bands of about 65,536 pixels with layers::parallel_map (one thread on wasm32) and join the results in row order. settled reads every pixel's neighbors from the labels as they were before any change, as before, without copying them. - settled_wide settles groups of its 7-column strips apart; each strip already started from empty buffers. - A picture with no pixels returns before any band or strip is made. The output does not change. --- crates/trace/src/quantize.rs | 146 +++++++++++++++++++++---- crates/trace/src/quantize/cut_edge.rs | 150 +++++++++++++++++--------- 2 files changed, 225 insertions(+), 71 deletions(-) diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index f5a2d9fa47..5f3a4e7081 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -10,6 +10,8 @@ mod rare; use cut_edge::{choosing_pixels, compact, settled, settled_wide, settles_wide}; pub(crate) use cut_edge::{continuous_tone, settle_cut_edges}; +#[cfg(test)] +use cut_edge::{settled_by, settled_wide_by}; pub use denoise::denoise; pub(crate) use denoise::denoise_keeping; pub use palette::FULL_TONE_MAX; @@ -191,6 +193,11 @@ fn own_colours(img: &Raster) -> Vec<bool> { /// [`own_colours`] among the pixels that `among` marks: the others are neither own colours nor /// the neighbours a pixel is a blend of. fn own_colours_among(img: &Raster, among: &[bool]) -> Vec<bool> { + own_colours_among_by(img, among, PIXELS_PER_SHARE) +} + +/// [`own_colours_among`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]). +fn own_colours_among_by(img: &Raster, among: &[bool], per_share: usize) -> Vec<bool> { let (w, h) = (img.width as isize, img.height as isize); let px = img.rgba.as_chunks::<4>().0; let at = |x: isize, y: isize| { @@ -198,28 +205,52 @@ fn own_colours_among(img: &Raster, among: &[bool]) -> Vec<bool> { let p = px.get(i).filter(|_| among.get(i).copied().unwrap_or(false))?; Some([f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]) }; - let mut out = Vec::with_capacity(px.len()); - for y in 0..h { - for x in 0..w { - let Some(p) = at(x, y) else { - out.push(false); - continue; - }; - let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| { - let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false }; - let d = [0, 1, 2].map(|k| b[k] - a[k]); - let len2: f64 = d.iter().map(|v| v * v).sum(); - if len2 <= 400.0 { - return false; - } - let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::<f64>() / len2; - let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum(); - t > 0.06 && t < 0.94 && off2 < len2 / 16.0 - }); - out.push(!blend); + over_rows(img.width as usize, img.height as usize, per_share, |rows| { + let mut out = Vec::with_capacity(rows.len() * img.width as usize); + for y in rows.start as isize..rows.end as isize { + for x in 0..w { + let Some(p) = at(x, y) else { + out.push(false); + continue; + }; + let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| { + let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false }; + let d = [0, 1, 2].map(|k| b[k] - a[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + if len2 <= 400.0 { + return false; + } + let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::<f64>() / len2; + let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum(); + t > 0.06 && t < 0.94 && off2 < len2 / 16.0 + }); + out.push(!blend); + } } + out + }) +} + +/// About how many pixels one thread works through at a time in the passes over every pixel that +/// [`over_rows`] spreads over the available cores. A picture of fewer pixels is worked through on +/// one thread. +const PIXELS_PER_SHARE: usize = 1 << 16; + +/// `f` of each band of rows of a picture `w` pixels wide and `h` rows high, each band about +/// `per_share` pixels (at least one row), spread over the available cores (one thread on the web), +/// and the results joined in row order. Each band's result depends only on its rows, so the +/// joined result is the same however many threads there are. A picture with no pixels gives no +/// result. +fn over_rows<R: Send>(w: usize, h: usize, per_share: usize, f: impl Fn(std::ops::Range<usize>) -> Vec<R> + Sync) -> Vec<R> { + if w == 0 || h == 0 { + return Vec::new(); } - out + let rows = (per_share / w.max(1)).max(1); + let bands: Vec<std::ops::Range<usize>> = (0..h).step_by(rows).map(|y| y..(y + rows).min(h)).collect(); + if bands.len() <= 1 { + return bands.into_iter().flat_map(&f).collect(); + } + crate::layers::parallel_map(&bands, |rows| f(rows.clone())).into_iter().flatten().collect() } /// Whether the palette is made from the picture's own colours alone (`own` of `all` opaque @@ -325,3 +356,78 @@ fn merge_mixed(q: Quantized, w: usize, h: usize, keep: &[[u8; 3]]) -> Quantized let labels = q.labels.iter().map(|&l| into.get(l as usize).map_or(l, |&t| t as u16)).collect(); compact(labels, color.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Rings of four colors around a point off the center, blended over three pixels at each edge, + /// with a little noise, and a band of half-transparent pixels along the bottom. + fn rings(w: u32, h: u32) -> Raster { + let colors = [[200.0, 30.0, 40.0], [20.0, 60.0, 180.0], [240.0, 210.0, 50.0], [25.0, 25.0, 25.0]]; + Raster::from_fn(w, h, |x, y| { + let r = (f64::from(x) - 0.4 * f64::from(w)).hypot(f64::from(y) - 0.55 * f64::from(h)) / 9.0; + let (ring, t) = (r.floor() as usize, ((r.fract() - 0.8) / 0.2).clamp(0.0, 1.0)); + let (a, b) = (colors[ring % 4], colors[(ring + 1) % 4]); + let noise = f64::from((x * 7 + y * 13) % 5) - 2.0; + let c = [0, 1, 2].map(|k| (a[k] + t * (b[k] - a[k]) + noise).clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }] + }) + } + + /// `img` labeled with the palette color nearest to each pixel (none for a transparent one). + fn nearest_labels(img: &Raster, palette: &[[u8; 3]]) -> Quantized { + let labels = img + .rgba + .as_chunks::<4>() + .0 + .iter() + .map(|p| { + let d = |c: &[u8; 3]| (0..3).map(|k| (i32::from(p[k]) - i32::from(c[k])).pow(2)).sum::<i32>(); + (0..palette.len()).min_by_key(|&i| d(&palette[i])).map_or(TRANSPARENT, |i| i as u16) + }) + .collect(); + Quantized { labels, palette: palette.to_vec() } + } + + #[test] + fn passes_over_every_pixel_give_the_same_result_however_the_picture_is_shared_out() { + // Odd sizes, so that the last band of rows and the last strip of columns are short. + let img = rings(173, 97); + let all = img.width as usize * img.height as usize; + // One row (or one strip) per share, a few rows per share, and the whole picture as one. + let shares = [1, 3 * 173 + 5, all]; + let opaque: Vec<bool> = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect(); + let own: Vec<Vec<bool>> = shares.iter().map(|&s| own_colours_among_by(&img, &opaque, s)).collect(); + assert_eq!(own[0].len(), all); + assert!(own[0].iter().any(|&o| !o), "the edges have blend pixels"); + assert!(own.iter().all(|o| *o == own[0])); + // Two colors for each blend, and an olive that the blends lie nearer to than to their sides. + let palette = [[200, 30, 40], [20, 60, 180], [240, 210, 50], [25, 25, 25], [120, 120, 60]]; + let q = nearest_labels(&img, &palette); + let own = &own[0]; + for (name, settle) in [ + ("settled", &(|s| settled_by(q.clone(), &img, own, s)) as &dyn Fn(usize) -> Quantized), + ("settled_wide", &|s| settled_wide_by(q.clone(), &img, own, false, s)), + ("settled_wide gray", &|s| settled_wide_by(q.clone(), &img, own, true, s)), + ] { + let out: Vec<Quantized> = shares.iter().map(|&s| settle(s)).collect(); + assert_ne!(out[0], q, "{name} settles some blend pixels"); + assert!(out.iter().all(|o| *o == out[0]), "{name}"); + } + } + + #[test] + fn a_picture_with_no_pixels_is_not_shared_out() { + // No band for each of the rows of a picture with no columns, however many rows it has. + assert!(over_rows(0, usize::MAX, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty()); + assert!(over_rows(5, 0, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty()); + let img = Raster::new(0, u32::MAX, vec![]).unwrap(); + assert!(own_colours_among(&img, &[]).is_empty()); + // No strip for each of the columns of a picture with no rows. + let img = Raster::new(u32::MAX, 0, vec![]).unwrap(); + let q = Quantized { labels: vec![], palette: vec![[0, 0, 0], [255, 255, 255]] }; + assert_eq!(settled_wide_by(q.clone(), &img, &[], false, PIXELS_PER_SHARE), q); + assert_eq!(settled_by(q.clone(), &img, &[], PIXELS_PER_SHARE), q); + } +} diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 27b7727b38..bc0bad05fc 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -1,5 +1,6 @@ use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab}; -use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab}; +use super::{Mode, PIXELS_PER_SHARE, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab, over_rows}; +use crate::layers::parallel_map; /// `q` with its blend pixels settled, and the colours that leaves unused dropped. /// @@ -10,59 +11,76 @@ use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, ok /// own pixels within two pixels of it takes the side it is nearer to of the pair of those colours /// it is best a blend of, when that explains it twice as well as its own colour does. With one /// colour around it, it keeps its own. -pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized { +pub(super) fn settled(q: Quantized, img: &Raster, own: &[bool]) -> Quantized { + settled_by(q, img, own, PIXELS_PER_SHARE) +} + +/// [`settled`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]). +pub(super) fn settled_by(mut q: Quantized, img: &Raster, own: &[bool], per_share: usize) -> Quantized { let (w, h) = (img.width as usize, img.height as usize); if q.labels.len() != w * h || own.len() != w * h { return q; } - let before = q.labels.clone(); + // Each pixel is settled by the labels as they were before any changed, so the bands of rows + // are settled apart and the changes made afterwards. + let before = &q.labels; let color = |l: u16| q.palette.get(l as usize).map(|c| c.map(f64::from)); let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>().sqrt(); - let mut changed = false; - for i in 0..w * h { - let mine_l = before[i]; - if own[i] || mine_l == TRANSPARENT { - continue; - } - let (x, y) = (i % w, i / w); - // The colours of the picture's own pixels within two pixels (seldom more than three). - let mut near: Vec<u16> = Vec::new(); - for yy in y.saturating_sub(2)..=(y + 2).min(h - 1) { - for xx in x.saturating_sub(2)..=(x + 2).min(w - 1) { - let j = yy * w + xx; - if own[j] && before[j] != TRANSPARENT && !near.contains(&before[j]) { - near.push(before[j]); + let changes: Vec<(usize, u16)> = over_rows(w, h, per_share, |rows| { + let mut changes = vec![]; + for i in rows.start * w..rows.end * w { + let mine_l = before[i]; + if own[i] || mine_l == TRANSPARENT { + continue; + } + let (x, y) = (i % w, i / w); + // The colours of the picture's own pixels within two pixels (seldom more than three). + let mut near: Vec<u16> = Vec::new(); + for yy in y.saturating_sub(2)..=(y + 2).min(h - 1) { + for xx in x.saturating_sub(2)..=(x + 2).min(w - 1) { + let j = yy * w + xx; + if own[j] && before[j] != TRANSPARENT && !near.contains(&before[j]) { + near.push(before[j]); + } } } - } - let (Some(p), Some(mine)) = (img.rgba.get(i * 4..i * 4 + 3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]), color(mine_l)) - else { - continue; - }; - if near.len() < 2 || near.contains(&mine_l) { - continue; - } - let mut best: Option<(f64, u16)> = None; - for (n, &a) in near.iter().enumerate() { - for &b in &near[n + 1..] { - let (Some(ca), Some(cb)) = (color(a), color(b)) else { continue }; - let d = [0, 1, 2].map(|k| cb[k] - ca[k]); - let len2: f64 = d.iter().map(|v| v * v).sum(); - let t = if len2 > 0.0 { ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0) } else { 0.0 }; - let off = dist(p, [0, 1, 2].map(|k| ca[k] + t * d[k])); - if best.is_none_or(|x| off < x.0) { - best = Some((off, if t < 0.5 { a } else { b })); + let (Some(p), Some(mine)) = (img.rgba.get(i * 4..i * 4 + 3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]), color(mine_l)) + else { + continue; + }; + if near.len() < 2 || near.contains(&mine_l) { + continue; + } + let mut best: Option<(f64, u16)> = None; + for (n, &a) in near.iter().enumerate() { + for &b in &near[n + 1..] { + let (Some(ca), Some(cb)) = (color(a), color(b)) else { continue }; + let d = [0, 1, 2].map(|k| cb[k] - ca[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + let t = if len2 > 0.0 { ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0) } else { 0.0 }; + let off = dist(p, [0, 1, 2].map(|k| ca[k] + t * d[k])); + if best.is_none_or(|x| off < x.0) { + best = Some((off, if t < 0.5 { a } else { b })); + } } } + if let Some((off, to)) = best + && off < 0.5 * dist(p, mine) + { + changes.push((i, to)); + } } - if let Some((off, to)) = best - && off < 0.5 * dist(p, mine) - { - q.labels[i] = to; - changed = true; + changes + }); + if changes.is_empty() { + return q; + } + for (i, to) in changes { + if let Some(l) = q.labels.get_mut(i) { + *l = to; } } - if changed { compact(q.labels, q.palette) } else { q } + compact(q.labels, q.palette) } /// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels @@ -122,12 +140,43 @@ const ON_THE_LINE: f64 = 0.15; /// their distance apart. Only the [`NEAR_COLORS`] colors nearest to its color are paired. It keeps /// its own color when that color is one of the pair, or when one color is all there is around it. /// With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]). -pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized { +pub(super) fn settled_wide(q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized { + settled_wide_by(q, img, own, gray, PIXELS_PER_SHARE) +} + +/// [`settled_wide`], with its strips of columns worked on in groups of about `per_share` pixels +/// (at least one strip), spread over the available cores (one thread on the web). +pub(super) fn settled_wide_by(mut q: Quantized, img: &Raster, own: &[bool], gray: bool, per_share: usize) -> Quantized { let (w, h) = (img.width as usize, img.height as usize); - if q.labels.len() != w * h || own.len() != w * h { + if q.labels.len() != w * h || own.len() != w * h || w == 0 || h == 0 { return q; } - let before = q.labels.clone(); + // Each pixel is settled by the labels as they were before any changed, so the strips are + // settled apart and the changes made afterwards. Strips are grouped so that each group holds + // about `per_share` pixels. + let strip = BLEND_STRIP.min(w).max(1); + let per_group = (per_share / (strip * h).max(1)).max(1); + let starts: Vec<usize> = (0..w).step_by(strip).collect(); + let groups: Vec<&[usize]> = starts.chunks(per_group).collect(); + let settle = |starts: &&[usize]| settled_wide_strips(&q, img, own, gray, starts); + let changes: Vec<(usize, u16)> = + if groups.len() <= 1 { groups.iter().flat_map(settle).collect() } else { parallel_map(&groups, settle).into_iter().flatten().collect() }; + if changes.is_empty() { + return q; + } + for (i, to) in changes { + if let Some(l) = q.labels.get_mut(i) { + *l = to; + } + } + compact(q.labels, q.palette) +} + +/// The labels that [`settled_wide`] changes in the strips of [`BLEND_STRIP`] columns that start at +/// the columns `starts`, with the pixel each one is at. +fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, starts: &[usize]) -> Vec<(usize, u16)> { + let (w, h) = (img.width as usize, img.height as usize); + let before = &q.labels; let colors: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect(); let dist2 = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>(); // The colors of the own pixels within reach of each pixel, as bits (one word per 64 palette @@ -135,7 +184,9 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b // sliding along each row, kept for the last 2 × reach + 1 rows), then across those rows. let words = q.palette.len().div_ceil(64).max(1); let span = 2 * BLEND_REACH + 1; - let strip = BLEND_STRIP.min(w); + let strip = BLEND_STRIP.min(w).max(1); + let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] }; + let mut changes = vec![]; let mut rows = vec![0u64; span * strip * words]; let mut counts = vec![0u32; q.palette.len()]; let mut window = vec![0u64; words]; @@ -178,11 +229,9 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b } } }; - let mut changed = false; let mut near: Vec<u16> = Vec::new(); let mut by_distance: Vec<(f64, u16)> = Vec::new(); - let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] }; - for (x0, y) in (0..w).step_by(strip.max(1)).flat_map(|x0| (0..h).map(move |y| (x0, y))) { + for (x0, y) in starts.iter().flat_map(|&x0| (0..h).map(move |y| (x0, y))) { let x1 = (x0 + strip).min(w); if y == 0 { for yy in 0..BLEND_REACH.min(h) { @@ -265,12 +314,11 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b && !pair.contains(&mine_l) && (off2 < BETTER_FIT * dist2(p, mine) || (between && off2 < ON_THE_LINE * ON_THE_LINE * apart2)) { - q.labels[i] = to; - changed = true; + changes.push((i, to)); } } } - if changed { compact(q.labels, q.palette) } else { q } + changes } /// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy From e2cdedc6d0ecd19363a9c3a480a565f1798799fb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 07:36:08 -0800 Subject: [PATCH 63/89] Docs: recount Indonesian and Bosnian on current main The Indonesian and Bosnian rows now give the catalog counts and the untranslated-report gaps measured on main at 676f0524cc: Indonesian has 6,825 rows (1,036 @msg) and lacks 60 menu strings and 2,508 scanned messages; Bosnian has 6,887 rows (1,144 @msg) and lacks 97 menu strings and 2,416 scanned messages. --- docs/localization-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 235b79e039..b3bc4c6622 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -36,7 +36,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 9,166 rows, 3,436 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 178 missing messages for it | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 9,378 rows, 3,563 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 55 missing messages for it, which open #7116, #7211, #7330, #7378 and #7405 translate; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | -| Indonesian | `id` | 6,819 rows, 1,033 of them `@msg` and 47 `@msgplural` (counted 2026-11-14 on `main` at `a47d7eec91`): the untranslated report lists 59 missing menu strings, 35 `tl!` literals and 13 undo labels for it; not in `COMPLETE_MESSAGES`, and checked against the same scanned messages it lacks 2,501; one plural form | most dialogs and tooltips; status and error messages partial | Latin | no | full | — (review) | +| Indonesian | `id` | 6,825 rows, 1,036 of them `@msg` and 47 `@msgplural` (counted 2026-11-14 on `main` at `676f0524cc`): the untranslated report lists 60 missing menu strings, 35 `tl!` literals and 13 undo labels for it; not in `COMPLETE_MESSAGES`, and checked against the same scanned messages it lacks 2,508; one plural form | most dialogs and tooltips; status and error messages partial | Latin | no | full | — (review) | | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,563 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 52 missing messages for it, which open #7116, #7211 and #7378 translate) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all (3,438 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 39 missing messages for it) | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | @@ -62,7 +62,7 @@ plus review. Native-speaker review is human work and isn't in the hours. | Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending | | Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending | | Czech | `cs` | 9,273 rows, 3,560 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 2 missing Czech messages, and 54 messages that no language has a row for | catalog complete; render-site gaps reduced; native review pending | -| Bosnian | `bs` | 6,881 rows, 1,141 of them `@msg` and 47 `@msgplural` (counted 2026-11-14 on `main` at `a47d7eec91`): the untranslated report lists 96 missing menu strings, 53 `tl!` literals and 27 undo labels for it; not in `COMPLETE_MESSAGES`, and checked against the same scanned messages it lacks 2,409; one/few/other plurals | full; AI-assisted, native review pending | +| Bosnian | `bs` | 6,887 rows, 1,144 of them `@msg` and 47 `@msgplural` (counted 2026-11-14 on `main` at `676f0524cc`): the untranslated report lists 97 missing menu strings, 53 `tl!` literals and 27 undo labels for it; not in `COMPLETE_MESSAGES`, and checked against the same scanned messages it lacks 2,416; one/few/other plurals | full; AI-assisted, native review pending | | Swedish | `sv` | 8,991 rows, 3,106 of them `@msg` and 48 `@msgplural` (counted 2026-11-14): the untranslated report lists 518 missing messages for it, and 12 menu strings, 1 `tl!` literal and 3 undo labels; one/other plurals | full; native review welcome | | Bulgarian | `bg` | 5,245 rows, 763 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial | full; AI-drafted, native review pending | | Greek | `el` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | full; native review welcome | From e0ac4324a53e1bf254c9ae18a8c450b34fa94908 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 07:39:39 -0800 Subject: [PATCH 64/89] Tests: Reset Panel tests start from a changed attribute Reset Panel records no undo step when it changes nothing, so the character and paragraph tests that reset a second document first give its type a value away from the panel's defaults. --- crates/ui-egui/src/panels/character/tests.rs | 2 ++ crates/ui-egui/src/panels/paragraph/tests.rs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index cdeafd2b00..adaf523e53 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -651,6 +651,8 @@ fn reset_panel_closes_only_its_own_undo_group() { app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); assert_eq!(app.session.active_index(), Some(1)); let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone(); + // A reset that changes nothing records no step, so B starts away from the panel's defaults. + app.run("text.setFormat", json!({"id": b, "antiAlias": "crisp"})).unwrap(); app.session.set_active(0); app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); app.run("text.setFormat", json!({"id": a, "antiAlias": "none"})).unwrap(); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 180f0b1fa1..4f52d3520c 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -1016,7 +1016,9 @@ fn reset_panel_leaves_an_undo_group_open_in_another_document() { // The second document is open before the group begins, so switching to it opens no group in it. app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); assert_eq!(app.session.active_index(), Some(1)); - app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap(); + let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone(); + // A reset that changes nothing records no step, so B starts away from the panel's defaults. + app.run("text.setFormat", json!({"id": b, "mojikumi": "none"})).unwrap(); app.session.set_active(0); app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); app.run("text.setFormat", json!({"id": a, "mojikumi": "none"})).unwrap(); From 978b94cef1bf5d3da3b27083a0ca169c1169711d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 07:29:25 -0800 Subject: [PATCH 65/89] i18n: localization doc marks Vietnamese messages as partial The Vietnamese row's message column said "all" while the same cell counts messages without a Vietnamese row. It now says dialogs and tooltips are complete and messages are partial, as the Indonesian row does. The row and the evidence line in docs/target-app-parity.md give the catalog's current counts: 1,750 @msg and 49 @msgplural rows, with 1,875 messages in the untranslated report. --- docs/localization-parity.md | 2 +- docs/target-app-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5866cb4070..e0b05fef03 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -40,7 +40,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,563 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 52 missing messages for it, which open #7116, #7211 and #7378 translate) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all (3,438 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 39 missing messages for it) | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | -| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all (1,746 `@msg` and 48 `@msgplural` rows, counted 2026-11-14: the untranslated report lists 1,868 messages without a Vietnamese row, 53 of which open #7211, #7378, #7405 and #7434 translate) | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | +| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | dialogs and tooltips all; messages partial (1,750 `@msg` and 49 `@msgplural` rows, counted 2026-11-14: the untranslated report lists 1,875 messages without a Vietnamese row, 53 of which open #7211, #7378, #7405 and #7434 translate) | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | **Historical 12-language score: ~82%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 100 + 100 + 100 + 98 + 100 + 100, over 12). **To `full` for all twelve: ~50–85 h** (historical estimate, not re-scored here). Hindi now has diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 422026a6c1..0dc9743e6d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -925,7 +925,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - The German catalog (`crates/ui-egui/src/i18n/de.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,563 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins, including the image export, color profile, color book, swatch exchange file, DXF import and Revolve errors that no other catalog translates yet. The report lists 52 German messages: the composite font and SVG filter errors that open #7211 and #7378 translate, and the justification order error that open #7116 translates. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The Korean catalog (`crates/ui-egui/src/i18n/ko.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,438 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 39 Korean messages; they are the composite font, select-a-path, justification order and `path.freehand` connect errors that open #7211, #7214, #7116 and #7330 translate. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - - The Vietnamese catalog (`crates/ui-egui/src/i18n/vi.tsv`) has an `@msg` row for 115 of the status and error messages that the untranslated report listed for every complete-message catalog, among them the image export, color profile, color book, swatch exchange file, swatch library, DXF and DWG import, 3D and Revolve errors and the `index` whole-number error, 1,746 `@msg` rows in all. The untranslated report (`untranslated_report`) lists 1,868 Vietnamese messages without a row; open #7211, #7378, #7405 and #7434 translate 53 of them. + - The Vietnamese catalog (`crates/ui-egui/src/i18n/vi.tsv`) has an `@msg` row for 115 of the status and error messages that the untranslated report listed for every complete-message catalog, among them the image export, color profile, color book, swatch exchange file, swatch library, DXF and DWG import, 3D and Revolve errors and the `index` whole-number error, 1,750 `@msg` rows in all. The untranslated report (`untranslated_report`) lists 1,875 Vietnamese messages without a row; open #7211, #7378, #7405 and #7434 translate 53 of them. - The Swedish catalog (`crates/ui-egui/src/i18n/sv.tsv`) translates status and error messages, among them the image export, color profile, color book, swatch exchange file, DXF import, 3D and Revolve errors and the `index` check of `document.activate`, 3,106 `@msg` rows in all. The untranslated report (`untranslated_report`) lists 518 Swedish messages, 54 of them messages that no catalog translates yet. - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. From 7d76b35710df5119ea5de802e9f985cf3e83f02d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 07:48:03 -0800 Subject: [PATCH 66/89] .ai save: type written as outlines keeps wrapping by its bounds Type that the editing copy writes as its outlines (mirrored type, type in a clip or symbol, type the text document can't hold) put its Text Wrap Options on the group of glyph outlines. The reader then wrapped area type around the glyph shapes instead of the type's bounds, so the wrap region changed when the file was opened. The group now ends with an unpainted closed rectangle at the type's geometric bounds, with the type's lock and hidden state, that carries the wrap options; the group keeps the type's name and has no options of its own. The `.ai` reader already reads an unpainted path with its own options (`text_wrap` in crates/eps/src/import/ai/read/document.rs), and wrap resolution descends into a group without options. The rectangle counts toward the object limit; without room for it the options are left out with the limit's warning. Type without finite bounds gets no rectangle, as it had no wrap region before saving. --- crates/eps/src/ai_editing/tests.rs | 90 +++++++++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 34 ++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index ce64e56fd5..c864dc9529 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -10584,6 +10584,96 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() { same_wraps(&wraps(top), &[(Some("Bent".into()), 2.5, true)]); } +/// Type written as its outlines wraps area type below it by its bounds, as it did before saving, +/// not by its glyphs: its text wrap options go on an unpainted rectangle of its bounds, the last +/// member of the outlines' group, which keeps the type's name and has no options of its own. +/// Mirrored point type with a positive offset, a negative offset with Invert Wrap, hidden and +/// locked, type of only white space, and no room for the rectangle within the object limit. +#[test] +fn type_written_as_outlines_keeps_wrapping_by_its_bounds() { + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + type Region = Vec<([f64; 4], f64, bool)>; + // The wrap shapes of the area type, in its own space: each one's bounds and options. + let region = |doc: &Document| -> Region { + let mut doc = doc.clone(); + vectorcraft_doc::text::resolve_wrap(None, &mut doc); + let top = doc.layers[0].children().unwrap(); + let Some(NodeKind::Text(t)) = top.iter().map(|n| &n.kind).find(|k| matches!(k, NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }))) + else { + panic!("no area type"); + }; + t.wrap + .iter() + .map(|s| { + let b = s.path.bounds().unwrap_or_default(); + ([b.x0, b.y0, b.x1, b.y1], s.wrap.offset, s.wrap.invert) + }) + .collect() + }; + let make = |text: &str, offset: f64, invert: bool, visible: bool, locked: bool| { + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut body = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK)); + body.runs = vec![TextRun::new("one two three four five six seven eight nine ten eleven twelve", style(10.0, Color::BLACK))]; + body.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 290.0, 190.0)) }; + body.xf = Affine::IDENTITY; + let body = named(text_node(&mut doc, body), "Body"); + let mut label = TextObject::point(vectorcraft_geom::Point::ZERO, text, style(60.0, Color::BLACK)); + // Mirrored, so written as its outlines. + label.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 150.0, 120.0]); + let mut label = wrapped(named(text_node(&mut doc, label), "Label"), offset, invert); + (label.visible, label.locked) = (visible, locked); + for n in [body, label] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc + }; + for (text, offset, invert, visible, locked) in + [("Ill", 4.5, false, true, false), ("Ill", -2.0, true, true, false), ("Ill", 3.0, false, false, true), (" ", 3.0, false, true, false)] + { + let doc = make(text, offset, invert, visible, locked); + let bounds = doc.layers[0].children().unwrap()[1].geometric_bounds(); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (region(&doc), region(&back)); + let same = before.len() == after.len() + && before + .iter() + .zip(&after) + .all(|(b, a)| b.0.iter().zip(a.0).all(|(x, y)| (x - y).abs() < 1e-3) && (b.1 - a.1).abs() < 1e-6 && b.2 == a.2); + assert!(same, "{text:?}\n{before:?}\n{after:?}\n{text_out}"); + let top = back.layers[0].children().unwrap(); + let group = &top[1]; + assert!(matches!(group.kind, NodeKind::Group { clip: false, .. }), "{text_out}"); + assert_eq!((group.name.as_deref(), group.extras().wrap, group.visible, group.locked), (Some("Label"), None, visible, locked)); + if let Some(want) = bounds { + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}"); + same_wraps(&wraps(top), &[(None, offset, invert)]); + let last = group.children().unwrap().last().unwrap(); + assert!(matches!(last.kind, NodeKind::Path { guide: false, .. }) && last.appearance.items.is_empty(), "{text_out}"); + assert_eq!((last.visible, last.locked), (visible, locked)); + let b = last.geometric_bounds().unwrap(); + assert!([b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-3), "{b:?} {want:?}"); + } else { + assert!(wraps(top).is_empty(), "{text_out}"); + } + } + // The rectangle counts toward the object limit. Without room for it, the type's wrap options + // are left out with the limit's warning, and the glyphs do not take them. + let doc = make("Ill", 4.5, false, true, false); + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let all = export_within(&doc, &opts(), read).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings); + let out = export_within(&doc, &opts(), read - 1).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + let text_out = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(!text_out.contains("(AI11TextWrapOffset)"), "{text_out}"); + let (back, _) = crate::ai_alone(&out.program).unwrap(); + let top = back.layers[0].children().unwrap(); + assert!(wraps(top).is_empty(), "{text_out}"); + assert_eq!(top[1].name.as_deref(), Some("Label"), "{text_out}"); +} + /// A clipping path inside art cut to a clipping object is written as its fills and its strokes, /// two objects: its text wrap options are written once, whether it has both or only strokes, and /// with its strokes when the region outside leaves nothing of its fills. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 6361efe5ff..b9d7229186 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -9,7 +9,7 @@ use vectorcraft_color::swatch::{REGISTRATION, Swatch}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer, TextWrap}; use vectorcraft_effects::stroke; -use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath}; +use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath, shapes}; use vectorcraft_pathops::BoolOp; use crate::ai_ops::{self, unit}; @@ -271,6 +271,9 @@ pub(super) struct Writer<'a> { /// brushed stroke's pieces) the art being written is in. Text wrap options are written only /// outside them, since only the document's own objects wrap type. drawn: u32, + /// The type just written as outlines has its text wrap options on the rectangle of its bounds + /// ([`Self::wrap_bounds`]), not in the group's dictionary. + wrap_moved: bool, /// The brushes written ([`brushes`]). brush_set: brushes::Brushes, /// Brushes may be written; false on a large canvas, where they are left out. @@ -350,6 +353,7 @@ impl<'a> Writer<'a> { styles: styles::Styles::default(), in_look: false, drawn: 0, + wrap_moved: false, brush_set: brushes::Brushes::default(), brushes_live: true, deform: false, @@ -829,7 +833,8 @@ impl<'a> Writer<'a> { /// deep, or its art doesn't fit. fn dictionary(&mut self, n: &Node) -> bool { let name = n.name.as_deref().filter(|s| !s.is_empty()); - let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look); + let moved = std::mem::take(&mut self.wrap_moved); + let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look && !moved); let placed = std::mem::take(&mut self.placed); let typed = std::mem::take(&mut self.typed); let bound = self.bound_variables(n, placed, typed); @@ -1404,7 +1409,8 @@ impl<'a> Writer<'a> { /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and /// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline /// graphics as their symbols' art where the layout places them, as the PDF page draws them; - /// the group has the type's transparency `xy`. + /// last, with text wrap options, the rectangle of its bounds ([`Self::wrap_bounds`]). The group + /// has the type's transparency `xy`. fn outlined_text(&mut self, n: &Node, xy: Xy) { if !self.open_group("u") { return; @@ -1460,12 +1466,34 @@ impl<'a> Writer<'a> { for c in over { self.node(c); } + // Type wraps by its bounds: its text wrap options go on a rectangle of its bounds, the + // group's last member, and the group's dictionary leaves them out. + if let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) { + self.wrap_bounds(n, w); + self.wrap_moved = true; + } if let Some((locked, hidden)) = state { self.state(locked, hidden); } self.end_group("U", xy); } + /// An unpainted closed rectangle at the geometric bounds of type `n`, the region its text wraps + /// around, with `n`'s lock and hidden state and text wrap options `w` in its dictionary. Nothing + /// when `n` has no finite bounds, or without room for it ([`Self::take_objects`]). + fn wrap_bounds(&mut self, n: &Node, w: TextWrap) { + let Some(b) = n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return }; + self.state(n.locked, !n.visible); + let objects = self.objects; + self.plain_object(&shapes::rectangle(b), FillRule::NonZero, None, None, Xy::PLAIN); + if self.objects == objects { + return; + } + self.body.push_str("%_/ArtDictionary :\n"); + self.text_wrap(w); + self.body.push_str("%_;\n%_\n"); + } + // ---------- color ---------- /// Set the fill (or with `stroke`, the stroke) to paint `p`: a spot or global swatch by its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..bf26476e53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1703,6 +1703,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps each wrap object's Text Wrap Options in its editing copy: the Offset, positive or negative, and Invert Wrap ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/wrap-or-unwrap-text-around-objects.html)), in the form the `.ai` reader reads them (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), so they come back when the file is opened. An offset past the -1000 to 1000 pt the reader keeps is written as the nearer end of that range. On a large canvas the offset is written at the art's scale and comes back at its size. Art the writer draws for an object, such as a blend's steps or a symbol instance written as its art, has only that object's options, and a clipping path written as its fills and its strokes has its options once (`crates/eps/src/ai_editing/tests.rs` `text_wrap_options_are_written_and_read_back`, `drawn_art_keeps_only_its_objects_text_wrap`, `text_wrap_offsets_on_a_large_canvas_come_back_at_their_size`, `text_wrap_options_of_type_and_envelopes_are_read_back`, `a_clipping_path_written_as_its_paint_keeps_one_text_wrap`). + Type written as its outlines in the editing copy still wraps area type by its bounds, not by its glyphs, when the file is opened: the outlines' group ends with an unpainted rectangle at the type's bounds that carries its Text Wrap Options, in the form the `.ai` reader reads a path's options (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), and the group keeps the type's name (`crates/eps/src/ai_editing/tests.rs` `type_written_as_outlines_keeps_wrapping_by_its_bounds`). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). From fbf2bee87b8c9f8db3d96a14fdf38a4206677215 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 07:57:00 -0800 Subject: [PATCH 67/89] MCP: a malformed artboard or history index is reported as not a whole number The artboard commands that take `index` or `indices`, a replayed `selectedArtboards`, the artboard Align and Distribute commands (`artboard`, `activeArtboard`), `edit.newDocumentFromState` and Paste in Place, in Front and in Back now report a value that is not a whole number with "`<key>` must be a whole number" or "`<key>` must contain whole numbers", naming the key. A whole number that names no artboard or state keeps `no such artboard`, `no history state N` or the paste range message. - A null artboard `index` is `missing index`, as for other required keys. - An `indices` value that is not a list, an empty list and a malformed entry each have their own message naming the key. - `edit.newDocumentFromState` still uses the current state for an omitted or null `index`. - Paste checks the form of both `artboard` and `artboards` before either range. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/artboardalign.rs | 16 +-- crates/engine/src/cmd/edit.rs | 41 ++++---- crates/engine/src/cmd/panelcmds.rs | 71 ++++++++----- crates/engine/src/tests/clipboard.rs | 39 +++++-- crates/engine/src/tests/history_document.rs | 21 +++- crates/engine/src/tests/journal.rs | 17 +-- crates/engine/src/tests/menucmds.rs | 18 +++- crates/engine/src/tests/panelcmds.rs | 108 +++++++++++++++++++- docs/mcp.md | 19 +++- docs/target-app-parity.md | 1 + 10 files changed, 265 insertions(+), 86 deletions(-) diff --git a/crates/engine/src/cmd/artboardalign.rs b/crates/engine/src/cmd/artboardalign.rs index 541ec94a4e..a3e585a8a0 100644 --- a/crates/engine/src/cmd/artboardalign.rs +++ b/crates/engine/src/cmd/artboardalign.rs @@ -6,8 +6,8 @@ use vectorcraft_doc::Scaling; use vectorcraft_geom::{Rect, Vec2}; use super::object::choice; -use super::panelcmds::{artboard_index, artboards_art, locked_and_hidden, named_or_selected, note_artboards}; -use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, switch_or}; +use super::panelcmds::{artboard_at, artboards_art, locked_and_hidden, named_or_selected, note_artboards, on_board}; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, index, switch_or}; use crate::EngineError; pub fn specs() -> Vec<CommandSpec> { @@ -63,13 +63,7 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> { check_switches(p, &["patterns"], cmd)?; let to_artboard = choice(p, cmd, "to", &["selection", "artboard"])? == Some("artboard"); let indices = named_or_selected(s, p, cmd)?; - let given = |key: &str| -> Result<Option<usize>> { - match p.get(key).filter(|v| !v.is_null()) { - Some(v) => artboard_index(&s.doc()?.doc, &json!({ "index": v }), cmd).map(Some), - None => Ok(None), - } - }; - let (named, recorded) = (given("artboard")?, given("activeArtboard")?); + let (named, recorded) = (artboard_at(&s.doc()?.doc, p, "artboard", cmd)?, artboard_at(&s.doc()?.doc, p, "activeArtboard", cmd)?); let move_art = switch_or(p, "moveArt", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true), cmd)?; let locked_art = locked_and_hidden(s, p, move_art, cmd)?; note_artboards(s, p, &indices); @@ -78,8 +72,8 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> { (false, _) => None, (true, Some(i)) => Some(i), (true, None) => { - let active = s.tool_options_of("artboard")["active"].clone(); - Some(artboard_index(&s.doc()?.doc, &json!({ "index": active }), cmd)?) + let active = s.tool_options_of("artboard")["active"].as_u64().map(index); + Some(active.map_or_else(|| Err(EngineError::Other("no such artboard".into())), |i| on_board(&s.doc()?.doc, i))?) } }; if let Some(i) = reference.filter(|_| named.is_none()) { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ecb5c3984..00b6a338f2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -808,11 +808,11 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> { let created = date_param(p, "created", C)?; let st = s.doc()?; // The row as the panel lists it now: keeping an edit in progress below changes the rows. - let chosen = match p.get("index") { - None | Some(Value::Null) => None, - Some(v) => { - let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row))); - Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone()) + let chosen = match super::index_opt(p, "index", C)? { + None => None, + Some(row) => { + let v = &p["index"]; + Some(st.history.state(&st.doc, row).ok_or_else(|| bad(C, format!("no history state {v}")))?.clone()) } }; settle_interaction(s)?; @@ -1276,23 +1276,28 @@ pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_ /// The artboards Paste in Place, in Front or in Back puts a copy on, in artboard order: `artboard` /// (the active one, #693), else `artboards`, else, while the Artboard tool is chosen with several /// artboards selected, those (noted in the journal as `artboards`, so a replay pastes on the same -/// ones); `None` when none is named. A named index that is not an artboard of the document, a -/// repeated one or an empty list is an error. +/// ones); `None` when none is named. A named value that is not a whole number (null included), a +/// named index that is not an artboard of the document, a repeated one or an empty list is an +/// error; every value is checked for its form before any is checked against the artboards. fn paste_targets(s: &mut Session, p: &Value, cmd: &str) -> Result<Option<Vec<usize>>> { let n = s.doc()?.doc.artboards.len(); - let one = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < n); - let single = match p.get("artboard") { - Some(v) => Some(one(v).ok_or_else(|| bad(cmd, "`artboard` must be the index of an artboard of the document"))?), - None => None, - }; - let listed = match p.get("artboards") { + let single = p.get("artboard").map(|v| v.as_u64().map(index).ok_or_else(|| bad(cmd, "`artboard` must be a whole number"))).transpose()?; + let entries = match p.get("artboards") { Some(v) => { let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`artboards` must be a list of artboard indices"))?; - let mut all = list - .iter() - .map(one) - .collect::<Option<Vec<usize>>>() - .ok_or_else(|| bad(cmd, "`artboards` names an artboard the document does not have"))?; + let all = list.iter().map(|i| i.as_u64().map(index)).collect::<Option<Vec<usize>>>(); + Some(all.ok_or_else(|| bad(cmd, "`artboards` must contain whole numbers"))?) + } + None => None, + }; + if single.is_some_and(|i| i >= n) { + return Err(bad(cmd, "`artboard` must be the index of an artboard of the document")); + } + let listed = match entries { + Some(mut all) => { + if all.iter().any(|i| *i >= n) { + return Err(bad(cmd, "`artboards` names an artboard the document does not have")); + } all.sort_unstable(); if all.windows(2).any(|w| w[0] == w[1]) { return Err(bad(cmd, "`artboards` names an artboard twice")); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2383d05a3a..f475aae760 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun}; use vectorcraft_geom::{Rect, Vec2}; -use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or}; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, switch_or}; use crate::{Clipboard, EngineError}; pub fn specs() -> Vec<CommandSpec> { @@ -137,19 +137,16 @@ pub(crate) fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result<Vec return artboard_indices(d, p, cmd); } if let Some(recorded) = p.get("selectedArtboards") { - return artboard_indices(d, &json!({ "indices": recorded }), cmd); + return listed_artboards(d, recorded, "selectedArtboards", cmd); } - let mut picked: Vec<u64> = t["selected"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_u64) - .filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len())) - .collect(); + let mut picked: Vec<usize> = + t["selected"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(index).filter(|i| *i < d.artboards.len()).collect(); if picked.is_empty() { - picked.push(t["active"].as_u64().unwrap_or(0)); + picked.push(on_board(d, t["active"].as_u64().map_or(0, index))?); } - artboard_indices(d, &json!({ "indices": picked }), cmd) + picked.sort_unstable(); + picked.dedup(); + Ok(picked) } /// Rename Artboards: one artboard takes the name, and several take it numbered in artboard order, in @@ -221,23 +218,51 @@ fn artboard_switch_orientation(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "switched": switched })) } -/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of -/// [`artboard_indices`] (`missing index`, `no such artboard`). +/// The one artboard `cmd` acts on: `index`, an artboard of `d`. Left out or null, it is +/// `missing index`; a value that is not a whole number is "`index` must be a whole number"; a +/// whole number past the last artboard is `no such artboard`. pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> { - let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?; - artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into())) + artboard_at(d, p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index")) +} + +/// The artboard of `d` that `key` of `p` names, or `None` when `p` leaves it out or gives null. A +/// value that is not a whole number is "`{key}` must be a whole number", and a whole number past the +/// last artboard is `no such artboard`. +pub(crate) fn artboard_at(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> { + super::index_opt(p, key, cmd)?.map(|i| on_board(d, i)).transpose() +} + +/// `i` when it is an artboard of `d`, else `no such artboard`. +pub(crate) fn on_board(d: &vectorcraft_doc::Document, i: usize) -> Result<usize> { + match i < d.artboards.len() { + true => Ok(i), + false => Err(EngineError::Other("no such artboard".into())), + } } /// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`, -/// in order and once each. +/// in order and once each. `indices` is read as [`listed_artboards`] reads it, and `index` as +/// [`artboard_index`] reads it. pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> { - let listed = match (p.get("indices"), p.get("index")) { - (Some(v), _) => point_indices(v), - (None, Some(i)) => point_indices(&json!([i])), - (None, None) => return Err(bad(cmd, "missing index")), - }; - let mut indices = - listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + match p.get("indices") { + Some(v) => listed_artboards(d, v, "indices", cmd), + None => Ok(vec![artboard_index(d, p, cmd)?]), + } +} + +/// `v`, the value of `key`, as artboards of `d`, in order and once each. A value that is not a list +/// is "`{key}` must be a list of artboard indices", an empty list is "`{key}` must be a non-empty +/// list of artboard indices", and a list with an entry that is not a whole number is "`{key}` must +/// contain whole numbers"; a whole number past the last artboard is `no such artboard`. Every entry +/// is checked for its form before any is checked against the artboards. +fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result<Vec<usize>> { + let entries = v.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be a list of artboard indices")))?; + if entries.is_empty() { + return Err(bad(cmd, format!("`{key}` must be a non-empty list of artboard indices"))); + } + let listed = entries.iter().map(Value::as_u64).collect::<Option<Vec<u64>>>(); + let listed = listed.ok_or_else(|| bad(cmd, format!("`{key}` must contain whole numbers")))?; + let mut indices = listed.into_iter().map(|i| on_board(d, index(i))).collect::<Result<Vec<usize>>>()?; indices.sort_unstable(); indices.dedup(); Ok(indices) diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 6cbfd909a9..be79004be2 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -1034,14 +1034,39 @@ fn in_place_pastes_put_a_copy_on_each_artboard_named() { // `artboard` wins over `artboards`. let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2, "artboards": [0, 3]})).unwrap()); assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 5.0), "{cmd}"); - // A list that is empty, names an artboard the document lacks or names one twice, or isn't a - // list: an error, and nothing pasted. - let before = doc(&s).clone(); - for bad in [json!([]), json!([0, 4]), json!([1, 1]), json!([0, -1]), json!(2), json!("0,1")] { - assert!(s.execute(cmd, &json!({"artboards": bad})).is_err(), "{cmd} with artboards {bad}"); + // A list that is empty, names an artboard the document lacks or names one twice, holds a + // value that is not a whole number, or isn't a list, and an `artboard` that is not a whole + // number or not an artboard: an error naming the fault, and nothing pasted. Both keys are + // checked when both are given. + let (before, undo) = (doc(&s).clone(), s.doc().unwrap().history.undo.len()); + let (not_list, missing, not_whole) = ( + "`artboards` must be a list of artboard indices", + "`artboards` names an artboard the document does not have", + "`artboards` must contain whole numbers", + ); + let cases = [ + (json!({"artboards": []}), not_list), + (json!({"artboards": 2}), not_list), + (json!({"artboards": "0,1"}), not_list), + (json!({"artboards": [0, 4]}), missing), + (json!({"artboards": [1, 1]}), "`artboards` names an artboard twice"), + (json!({"artboards": [0, -1]}), not_whole), + (json!({"artboards": [0, 1.5]}), not_whole), + (json!({"artboards": [4, "1"]}), not_whole), + (json!({"artboard": 1, "artboards": [7]}), missing), + (json!({"artboard": 1, "artboards": ["1"]}), not_whole), + (json!({"artboard": 4}), "`artboard` must be the index of an artboard of the document"), + (json!({"artboard": -1}), "`artboard` must be a whole number"), + (json!({"artboard": 1.5}), "`artboard` must be a whole number"), + (json!({"artboard": "1"}), "`artboard` must be a whole number"), + (json!({"artboard": null}), "`artboard` must be a whole number"), + (json!({"artboard": "1", "artboards": [9]}), "`artboard` must be a whole number"), + ]; + for (p, want) in cases { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert_eq!(e, format!("invalid parameters for `{cmd}`: {want}"), "{cmd} {p}"); } - assert!(s.execute(cmd, &json!({"artboard": 1, "artboards": [7]})).is_err(), "{cmd}: a bad list with `artboard`"); - assert_eq!(doc(&s), &before, "{cmd}"); + assert_eq!((doc(&s), s.doc().unwrap().history.undo.len()), (&before, undo), "{cmd}"); } } diff --git a/crates/engine/src/tests/history_document.rs b/crates/engine/src/tests/history_document.rs index 3c2c9b9eb4..9e05eabeac 100644 --- a/crates/engine/src/tests/history_document.rs +++ b/crates/engine/src/tests/history_document.rs @@ -79,18 +79,29 @@ fn the_source_stays_as_it_is() { assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved"); } -/// An index that names no state, or is not a whole number, is an error and changes nothing: no -/// document, no step and no Untitled number is used up. +/// An index that names no state (`no history state N`), or is not a whole number ("`index` must be +/// a whole number"), is an error and changes nothing: no document, no step and no Untitled number +/// is used up. #[test] fn a_bad_index_is_an_error_and_changes_nothing() { let mut s = three_rectangles_one_undone(); let source = s.doc().unwrap().doc.clone(); let untitled = s.peek_untitled(); - let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)]; - for index in bad { + let bad: [(Value, bool); 8] = [ + (json!(4), true), + (json!(99), true), + (json!(u64::MAX), true), + (json!(-1), false), + (json!(1.5), false), + (json!("1"), false), + (json!(true), false), + (json!([1]), false), + ]; + for (index, whole) in bad { let e = s.execute(CMD, &json!({ "index": index })).unwrap_err(); assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}"); - assert!(e.to_string().contains(&format!("no history state {index}")), "{e}"); + let want = if whole { format!("no history state {index}") } else { "`index` must be a whole number".to_string() }; + assert_eq!(e.to_string(), format!("invalid parameters for `{CMD}`: {want}"), "{index}"); assert_eq!(s.documents().len(), 1, "{index}"); assert_eq!(steps(&s), (2, 1), "{index}"); assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}"); diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 1c4287e9c1..700c17883d 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -611,15 +611,18 @@ fn edit_copy_and_cut_read_the_recorded_artboards() { s.execute("select.none", &json!({})).unwrap(); assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}"); let before = documents(&s); - for bad in [ - json!({"selectedArtboards": [9]}), - json!({"selectedArtboards": []}), - json!({"selectedArtboards": "1"}), - json!({"selectedArtboards": null}), - json!({"selectedArtboards": [1], "art": "yes"}), + for (bad, want) in [ + (json!({"selectedArtboards": [9]}), "no such artboard"), + (json!({"selectedArtboards": []}), "`selectedArtboards` must be a non-empty list of artboard indices"), + (json!({"selectedArtboards": "1"}), "`selectedArtboards` must be a list of artboard indices"), + (json!({"selectedArtboards": null}), "`selectedArtboards` must be a list of artboard indices"), + (json!({"selectedArtboards": [1, "0"]}), "`selectedArtboards` must contain whole numbers"), + (json!({"selectedArtboards": [1, -1]}), "`selectedArtboards` must contain whole numbers"), + (json!({"selectedArtboards": [1], "art": "yes"}), "`art` must be true or false"), ] { let err = s.execute(id, &bad).unwrap_err().to_string(); - assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}"); + assert!(err.ends_with(want), "{id} {bad}: {err}"); + assert!(err.contains(id) || want == "no such artboard", "{id} {bad}: {err}"); assert_eq!(documents(&s), before, "{id} {bad}"); assert!(s.clipboard.artboards.is_empty(), "{id} {bad}"); } diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index abb0bf31eb..e8c4e46af3 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1979,9 +1979,10 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() { assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default"); } -/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names -/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as -/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first +/// Artboard Options and Fit to Selected Art fail with `no such artboard` on a whole-number `index` +/// past the last artboard, with "`index` must be a whole number" on one that is negative, +/// fractional or a string, and with `missing index` on a null one, and the artboards stay as they +/// were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first /// artboard. #[test] fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() { @@ -1992,9 +1993,16 @@ fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboa s.execute("paint.setStroke", &json!({"none": true})).unwrap(); let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(); let (was, n) = (boards(&s), undo_len(&s)); - for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] { + for (index, error) in [ + (json!(99), None), + (json!(-1), Some("`index` must be a whole number")), + (json!(1.5), Some("`index` must be a whole number")), + (json!("1"), Some("`index` must be a whole number")), + (Value::Null, Some("missing index")), + ] { for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] { - assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + let want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}")); + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}"); } } let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string(); diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 8174cf36d1..715bf2b8ce 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -492,9 +492,11 @@ fn new_and_renamed_artboards_trim_their_names() { assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]); } -/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an -/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the -/// artboards, the art and the clipboard stay as they were. +/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on a +/// whole-number `index` past the last artboard, with "`index` must be a whole number" on one that +/// is negative, fractional, a string, true or a list, and with `missing index` on a null one, +/// instead of acting on the Artboard tool's active artboard. The artboards, the art and the +/// clipboard stay as they were. #[test] fn artboard_copies_refuse_an_index_that_names_no_artboard() { let mut s = session(); @@ -502,10 +504,22 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() { s.execute("artboard.new", &json!({})).unwrap(); s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap()); let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len()); - for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] { + let not_whole = Some("`index` must be a whole number"); + let cases = [ + (json!(99), None), + (json!(u64::MAX), None), + (json!(-1), not_whole), + (json!(1.5), not_whole), + (json!("1"), not_whole), + (json!(true), not_whole), + (json!([1]), not_whole), + (serde_json::Value::Null, Some("missing index")), + ]; + for (index, error) in cases { for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] { let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) }; - assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + let want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}")); + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}"); } } let st = s.doc().unwrap(); @@ -513,6 +527,90 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() { assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied"); } +/// The artboard commands that take `indices` (and `selectedArtboards`, a replay of the journal) +/// name the fault: a value that is not a list, an empty list and a list with an entry that is not a +/// whole number each have their own message naming the key, and a whole number past the last +/// artboard is `no such artboard`, also when another entry is malformed after it. The artboards, +/// the art, the undo history and the clipboard stay as they were. +#[test] +fn artboard_lists_name_the_fault_and_the_key() { + let mut s = session(); + rect(&mut s); + s.execute("artboard.new", &json!({})).unwrap(); + let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len()); + let cmds = [ + ("artboard.duplicate", json!({})), + ("artboard.copy", json!({})), + ("artboard.cut", json!({})), + ("artboard.rename", json!({"name": "A"})), + ("artboard.switchOrientation", json!({})), + ("artboard.delete", json!({})), + ("artboard.fitToArt", json!({})), + ("artboard.move", json!({"dx": 10, "dy": 0})), + ("artboard.align", json!({"horizontal": "left"})), + ]; + for key in ["indices", "selectedArtboards"] { + let cases = [ + (json!(null), format!("`{key}` must be a list of artboard indices")), + (json!(1), format!("`{key}` must be a list of artboard indices")), + (json!("0,1"), format!("`{key}` must be a list of artboard indices")), + (json!([]), format!("`{key}` must be a non-empty list of artboard indices")), + (json!([0, -1]), format!("`{key}` must contain whole numbers")), + (json!([0, 1.5]), format!("`{key}` must contain whole numbers")), + (json!(["1"]), format!("`{key}` must contain whole numbers")), + (json!([null]), format!("`{key}` must contain whole numbers")), + (json!([9, "1"]), format!("`{key}` must contain whole numbers")), + (json!([0, 9]), "no such artboard".to_string()), + ]; + for (cmd, base) in &cmds { + if key == "selectedArtboards" && matches!(*cmd, "artboard.delete" | "artboard.fitToArt" | "artboard.move") { + continue; + } + for (value, want) in &cases { + let mut p = base.clone(); + p[key] = value.clone(); + let want = if want.starts_with('`') { format!("invalid parameters for `{cmd}`: {want}") } else { want.clone() }; + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}"); + } + } + } + let st = s.doc().unwrap(); + assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n)); + assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied"); +} + +/// Align, Distribute and Distribute Spacing for artboards name the key that holds a malformed +/// artboard to align to (`artboard` or `activeArtboard`), not `index`; a null one is left out, and +/// a whole number past the last artboard is `no such artboard`. +#[test] +fn artboard_align_names_the_key_of_a_malformed_reference() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.new", &json!({})).unwrap(); + let cmds = [ + ("artboard.align", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})), + ("artboard.distribute", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})), + ("artboard.distributeSpacing", json!({"indices": [0, 1, 2], "to": "artboard"})), + ]; + for (cmd, base) in &cmds { + for key in ["artboard", "activeArtboard"] { + let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len()); + for value in [json!(-1), json!(1.5), json!("1"), json!(true)] { + let mut p = base.clone(); + p[key] = value; + let want = format!("invalid parameters for `{cmd}`: `{key}` must be a whole number"); + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}"); + } + let mut p = base.clone(); + p[key] = json!(9); + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + assert_eq!((boards(&s), s.doc().unwrap().history.undo.len()), (was, n), "{cmd} {key}: nothing moved"); + p[key] = json!(null); + s.execute(cmd, &p).unwrap(); + } + } +} + /// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an /// option given as text or as another value that is not true or false, such as "true", and /// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..46ec651ecd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -667,7 +667,7 @@ sampler. | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | -| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | +| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted or null `index` uses the current state. An `index` that names no state fails with `no history state N`, one that is not a whole number with `` `index` must be a whole number ``, and either changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | For example, create editable Japanese area type with the engine's vertical writing mode: @@ -6745,7 +6745,10 @@ copies in artboard order); `artboard` wins when both are given. Without either, several artboards selected, they paste a copy on each of those, and the journal records them as `artboards`; a recorded action leaves both out. Art copied from no artboard keeps its place on the first artboard named and its offset to that one on the others. An index the document does not have, a repeated index, a value that is not an index -or an empty list fails, and nothing is pasted. Copied artboards come back once, where they were, whichever artboards +or an empty list fails, and nothing is pasted. An `artboard` that is not a whole number, null included, fails with +`` `artboard` must be a whole number ``, and an `artboards` entry that is not one with +`` `artboards` must contain whole numbers ``; both keys are checked when both are given. Copied artboards come back +once, where they were, whichever artboards are named. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`) pastes objects back into the layers and sublayers they came from: in the same document while those are still there, else by the names of the layer and the layers around it (the topmost chain that matches the most names, a shown and @@ -6788,9 +6791,15 @@ also move the pattern tiles of the copies when `patterns` is true (default: pref `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard.move` and `artboard.rearrange` take `lockedAndHidden?` (default: prefs `moveLockedWithArtboard`), and with the Artboard tool chosen `edit.copy` and `edit.cut` pass it on. When art goes along, the journal records the value, so a replay under another preference takes the same art. -Every artboard command that takes an `index` fails with `no such artboard` when the `index` is not the -0-based index of an artboard (past the last one, negative, fractional, a string or null), and the document and the -clipboard stay as they were. +Every artboard command that takes an `index` fails with `no such artboard` when the `index` is a whole number past +the last artboard, with `` `index` must be a whole number `` when it is negative, fractional, a string or another value +that is not a whole number, and with `missing index` when it is null, also in a command that picks an artboard when +`index` is left out. An `indices` (or a replayed `selectedArtboards`) that is not a list fails with +`` `indices` must be a list of artboard indices ``, an empty one with +`` `indices` must be a non-empty list of artboard indices ``, one with an entry that is not a whole number with +`` `indices` must contain whole numbers ``, each naming its key, and one with a whole number past the last artboard +with `no such artboard`. `artboard.align`, `artboard.distribute` and `artboard.distributeSpacing` name `artboard` or +`activeArtboard` the same way. The document and the clipboard stay as they were. These artboard commands refuse a string, a number or another value that is not true or false with `` `art` must be true or false ``, naming the key, and leave the document, its undo history and the clipboard as they were: `moveArt`, `scaleArt`, `strokes`, `corners`, `patterns`, `lockedAndHidden` and an artboard's `locked`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..79c702c838 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1133,6 +1133,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. `path.setHandle`, `path.moveAnchor`, `text.setPathHandle` (`anchor`), `path.insertAnchor` (`segment`), `artboard.reorder`, `library.moveGroup`, `graphicStyle.move` (`to`), `path.appendAnchor`, `path.close`, `path.reverse` (`subpath`) and the ruler origin commands (`artboard`) refuse a value that is not a whole number of 0 or more, such as `"3"` or `2.5`, with `` `anchor` must be a whole number ``, naming the key, instead of reporting it as missing or reading it as the default subpath or artboard; a null `artboard` is artboard 0 (`tests::params::a_malformed_index_is_not_missing_or_its_default`). + The artboard commands that take `index`, `indices` or a replayed `selectedArtboards`, the artboard Align and Distribute commands (`artboard`, `activeArtboard`), `edit.newDocumentFromState` (`index`) and Paste in Place, in Front and in Back (`artboard`, `artboards`) report a value that is not a whole number as such, naming the key, and keep `no such artboard`, `no history state N` and the paste range messages for a whole number that names nothing; a null artboard `index` is `missing index`, and an `indices` value that is not a list, an empty list and a malformed entry each have their own message (`tests::panelcmds::artboard_lists_name_the_fault_and_the_key`). `path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged. Direct Selection's marquee selects the frame anchors of area type that lie inside it, and a click on a frame anchor selects that one; Shift toggles them. The selected frame anchors show filled. A drag of one of them moves all of them, the arrow keys nudge them, and the type reflows at its size (`text.reshapeArea`, which takes `items` to move the anchors of several frames in one step), as [Create text](http://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) and [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describe. When the selection also holds other objects, path anchors or ruler guides, or when another change to the document or the selection follows the pick, the type is selected whole. A press on a selected object's centre point (Attributes panel › Show Center) selects the object whole, and a drag from it moves the object as one Move, held by that point (#4891; test `the_centre_point_selects_and_moves_the_object_whole`). From 10f94b544d37b6096b280ff981e6d9829340c894 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 07:57:56 -0800 Subject: [PATCH 68/89] MCP: livePaint.info lists every appearance row of a face or edge livePaint.info reported only the top fill of each face and the top stroke and weight of each edge, so a lower row that draws under an unpainted top row was not visible to a client. Each face and edge now also carries `appearance`: every fill and stroke row, bottom first, as {index, kind, paint, opacity, visible, width (strokes only), drawn}. `drawn` follows Expand's rule row by row: the part is shown with an opacity above 0, and the row is shown, has paint and an opacity above 0, and a stroke has a weight above 0. The existing `fill`, `stroke`, `width`, `bounds`, `sources`, `gapOptions` and `gaps` fields are unchanged. The params text no longer says the group defaults to the selection: the command takes `group`, or `id` when `group` is left out, and returns an error without either. --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 54 ++++++++++++-- crates/engine/src/tests/build.rs | 70 +++++++++++++++++++ docs/mcp.md | 9 ++- docs/target-app-parity.md | 1 + 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 06ec06e348..038a4202a6 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> { "Live Paint Info", [], None, - "{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}", + "{group?|id?: Live Paint group id (one required; id is read when group is left out)} → {faces: [{id, fill (top fill), bounds, appearance: [row…]}], edges: [{id, stroke (top stroke), width (top stroke's), appearance: [row…]}], where a row is {index (in the part's fill and stroke stack, bottom first), kind: \"fill\"|\"stroke\", paint, opacity: 0..1, visible, width?: pt (strokes only), drawn (the part is shown with opacity above 0, and the row is shown, has paint and opacity above 0, a stroke also width above 0)}, sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}", has_doc, lp_info ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 9c3c750996..1554ef7328 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,8 @@ use super::{ - Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect, - Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json, - number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Appearance, AppearanceItem, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, + PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, + is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, + switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -524,6 +525,37 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "group": g.0, "edge": edge.0 })) } +/// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A +/// row is `drawn` when the part is shown with an opacity above 0 and the row is shown, has paint +/// and has an opacity above 0 (a stroke also a weight above 0): the rule of [`draws`], row by row. +fn appearance_rows(n: &Node) -> Vec<Value> { + let part_shown = n.visible && n.opacity > 0.0; + n.appearance + .items + .iter() + .enumerate() + .map(|(i, item)| { + let shown = part_shown && item.visible() && !item.paint().is_none() && item.opacity() > 0.0; + let mut row = json!({ + "index": i, + "kind": item.kind_name(), + "paint": item.paint().label(), + "opacity": item.opacity(), + "visible": item.visible(), + }); + let drawn = match item { + AppearanceItem::Fill(_) => shown, + AppearanceItem::Stroke(st) => { + row["width"] = json!(st.width); + shown && st.width > 0.0 + } + }; + row["drawn"] = json!(drawn); + row + }) + .collect() +} + pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> { let g = match object_param(p, "group", "livePaint.info")? { Some(g) => g, @@ -535,12 +567,24 @@ pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> { .iter() .map(|f| { let bb = f.geometric_bounds().unwrap_or(Rect::ZERO); - json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] }) + json!({ + "id": f.id.0, + "fill": f.appearance.fill_paint().label(), + "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], + "appearance": appearance_rows(f), + }) }) .collect(); let edges: Vec<Value> = b::edges(n) .iter() - .map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() })) + .map(|e| { + json!({ + "id": e.id.0, + "stroke": e.appearance.stroke_paint().label(), + "width": e.appearance.stroke_width(), + "appearance": appearance_rows(e), + }) + }) .collect(); let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0); let options = n.gap_options(); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index e0d68ee949..712f658920 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3018,6 +3018,76 @@ fn live_paint_expand_keeps_the_faces_and_edges_that_draw() { assert_eq!(rows(&n, lower_stroke), rows(&before, lower_stroke)); } +/// `livePaint.info` lists every fill and stroke row of each face and edge, bottom first, and says +/// which rows draw by Expand's rule, while `fill`, `stroke` and `width` stay the top rows. +#[test] +fn live_paint_info_lists_every_appearance_row() { + use vectorcraft_doc::{FillLayer, StrokeLayer}; + let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p)); + let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w)); + let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let blue = || Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + let green = || Paint::solid(Color::rgb(0.0, 1.0, 0.0)); + let hidden = |mut i: AppearanceItem| { + match &mut i { + AppearanceItem::Fill(f) => f.visible = false, + AppearanceItem::Stroke(st) => st.visible = false, + } + i + }; + let faded = |mut i: AppearanceItem, o: f32| { + match &mut i { + AppearanceItem::Fill(f) => f.opacity = o, + AppearanceItem::Stroke(st) => st.opacity = o, + } + i + }; + let mut s = session(); + let g = live(&mut s); + // A drawn lower fill and a half-opaque stroke under an unpainted top fill. + let lower = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), faded(stroke(blue(), 2.0), 0.5), fill(Paint::None)], 1.0); + let quiet = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red())), faded(fill(red()), 0.0)], 1.0); + let bare = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![], 1.0); + // Two strokes with a fill between them. + let two = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 4.0), fill(green()), stroke(blue(), 2.0)], 1.0); + let thin = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0); + let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0); + let hidden_edge = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![stroke(red(), 2.0)], 1.0); + s.edit("t", |d, _| { + d.node_mut(hidden_edge).unwrap().visible = false; + Ok(()) + }) + .unwrap(); + let parts = [lower, quiet, bare, two, thin, clear_edge, hidden_edge]; + assert_eq!(parts.iter().collect::<std::collections::HashSet<_>>().len(), parts.len()); + + let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap(); + let part = |key: &str, id: NodeId| info[key].as_array().unwrap().iter().find(|p| p["id"] == id.0).unwrap().clone(); + let fill_row = |i: usize, p: Paint, o: f64, v: bool, drawn: bool| json!({"index": i, "kind": "fill", "paint": p.label(), "opacity": o, "visible": v, "drawn": drawn}); + let stroke_row = |i: usize, p: Paint, o: f64, v: bool, w: f64, drawn: bool| json!({"index": i, "kind": "stroke", "paint": p.label(), "opacity": o, "visible": v, "width": w, "drawn": drawn}); + + let f = part("faces", lower); + assert_eq!(f["fill"], json!(Paint::None.label()), "fill stays the top fill"); + assert!(f["bounds"].is_array()); + assert_eq!( + f["appearance"], + json!([fill_row(0, red(), 1.0, true, true), stroke_row(1, blue(), 0.5, true, 2.0, true), fill_row(2, Paint::None, 1.0, true, false)]) + ); + assert_eq!(part("faces", quiet)["appearance"], json!([fill_row(0, red(), 1.0, false, false), fill_row(1, red(), 0.0, true, false)])); + assert_eq!(part("faces", bare)["appearance"], json!([])); + + let e = part("edges", two); + assert_eq!((e["stroke"].clone(), e["width"].clone()), (json!(blue().label()), json!(2.0)), "stroke and width stay the top stroke's"); + assert_eq!( + e["appearance"], + json!([stroke_row(0, red(), 1.0, true, 4.0, true), fill_row(1, green(), 1.0, true, true), stroke_row(2, blue(), 1.0, true, 2.0, true)]) + ); + assert_eq!(part("edges", thin)["appearance"], json!([stroke_row(0, red(), 1.0, true, 0.0, false)])); + for id in [clear_edge, hidden_edge] { + assert_eq!(part("edges", id)["appearance"], json!([stroke_row(0, red(), 1.0, true, 2.0, false)]), "a part that is not shown draws no row"); + } +} + /// Merge carries a face's whole appearance when one of its rows draws, and carries nothing from a /// face that draws nothing. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..02bb2c7f1a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3603,7 +3603,7 @@ already walks Image Trace. Short agent entry points: is changed, renamed or deleted. In the app, `ui.imageTracePresetDialog {params?, rename?}` opens the Image Trace panel's Save as New Preset… or Rename… dialog. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, - `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, + `livePaint.info {group?|id?}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most covering source, including compound paths with shared curved boundaries. Expand keeps those face fills. `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity @@ -3612,6 +3612,13 @@ already walks Image Trace. Short agent entry points: row is hidden is removed. `livePaint.merge` copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. + `livePaint.info` lists each face's top fill as `fill` and each edge's top stroke and its weight as `stroke` and + `width`, and lists every fill and stroke row of each face and edge in `appearance`, bottom first, as + `{index, kind, paint, opacity, visible, width?, drawn}`: `index` counts fills and strokes together, `opacity` runs + from 0 to 1, and `width` (strokes only) is the weight in points. `drawn` is true when the row draws by the rule + above: the part is shown with an opacity above 0, and the row is shown, has paint and has an opacity above 0 (a + stroke also a weight above 0). An unpainted top row can sit over a lower row that draws, so `fill`, `stroke` and + `width` alone may not describe what the part draws. With `detect: true`, paint stops at the gaps that run from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..8b37f43c17 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1496,6 +1496,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients, live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts}`. + `livePaint.info` lists every fill and stroke row of each face and edge, bottom first, with its paint, opacity, visibility, weight for a stroke, and whether it draws by the rule above, beside the top fill, stroke and weight it already reported. Evidence: `tests::build::live_paint_info_lists_every_appearance_row`. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). From 93f9ec034652fe3b813c9a7036ee15c1d61af6ec Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 07:59:21 -0800 Subject: [PATCH 69/89] Docs: only learn.markStep checks a tutorial id, not learn.resetProgress docs/mcp.md said that for both learn.markStep and learn.resetProgress a missing, non-string or unknown `tutorial` is an error. learn.resetProgress takes no params and clears the progress without reading `tutorial` (crates/ui-egui/src/panels/learn.rs), so the sentence now names the tutorial errors under learn.markStep alone, and the rule that an error changes nothing stays with them. --- docs/mcp.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..eea075b537 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2952,11 +2952,11 @@ finished and every step marked. The desktop app keeps the progress in its prefer when two windows change it, the one that saves last keeps its progress. The web build keeps it while the page is open. Marking steps by hand, keeping the progress and Reset progress are VectorCraft's own rules. `learn.markStep {tutorial, step, complete?}` runs a step's check box → `{tutorial, step, complete}`: `step` is the -zero-based step number, and `complete` marks the step done (`true`, omitted or null) or not done (`false`). A missing -`step` or one the tutorial does not have, a `complete` that is not true or false, a tutorial whose sample was not -opened, and a finished tutorial are errors. `learn.resetProgress {}` runs Reset progress and answers what `learn.list` -answers; open documents, opened samples and the Learn view stay as they are. For both, a missing, non-string or -unknown `tutorial` is an error, and an error changes nothing. +zero-based step number, and `complete` marks the step done (`true`, omitted or null) or not done (`false`). A missing, +non-string or unknown `tutorial`, a missing `step` or one the tutorial does not have, a `complete` that is not true or +false, a tutorial whose sample was not opened, and a finished tutorial are errors, and an error changes nothing. +`learn.resetProgress {}` runs Reset progress and answers what `learn.list` answers; open documents, opened samples and +the Learn view stay as they are. The Home screen has a Learn section between Recent Files and the new-file presets, as [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/homescreen-overview.html) From 4c67b8f73cc019aafa2754170689b82f0ae28952 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:02:17 -0800 Subject: [PATCH 70/89] Graphs: swatch edits reach the kept black legend fill of graphs that draw it Only a pie with Legends in Wedges picks its legend labels' fill, white on dark wedges. Paint visits and swatch mappings now leave its default black legend fill out only for such a pie; other graphs draw the kept fill, so it is visited and mapped like the other label fills. A color mode change still leaves the default black legend fill as the default, so turning on Legends in Wedges later keeps the white labels on dark wedges. --- crates/doc/src/graph.rs | 8 ++++ crates/doc/src/swatches.rs | 56 ++++++++++++++++----------- crates/engine/src/cmd/graph/series.rs | 4 +- crates/engine/src/cmd/graph/tests.rs | 21 ++++++++++ 4 files changed, 64 insertions(+), 25 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index b387eecb2a..2746390b86 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -548,6 +548,14 @@ impl GraphSpec { self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind) } + /// Does the graph pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie + /// with Legends in Wedges whose legend labels keep no fill of their own ([`LabelStyles::legend_fill_is_own`])? Only + /// then is a black legend fill it keeps the default, which edits that map the kept paints leave as it is; other + /// graphs draw the kept fill as it is. + pub fn picks_legend_fill(&self) -> bool { + self.kind == GraphKind::Pie && self.legend && self.pie_legend_in_wedges && !self.label_styles.legend_fill_is_own() + } + /// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows /// of [`MAX_GRAPH_SERIES`] cells). pub fn cells(&self) -> Vec<Vec<Option<f64>>> { diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index cab2115fda..63951d8104 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -406,12 +406,13 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator<Item = &mu .chain(rows.flat_map(|r| [r.paint.fill.as_mut(), r.paint.stroke.as_mut()]).flatten()) } -/// The place in [`graph_paints`] of the legend fill graph `n` keeps when it is the default black and not the legend -/// labels' own ([`crate::graph::LabelStyles::legend_fill_is_own`]). +/// The place in [`graph_paints`] of the legend fill graph `n` keeps when it is the default black of a graph that picks +/// its legend labels' fill ([`crate::graph::GraphSpec::picks_legend_fill`]). Other graphs draw the kept fill as it is, +/// so it is visited and mapped. fn default_legend_fill(n: &Node) -> Option<usize> { let g = n.extras().graph.as_deref()?; let l = &g.label_styles; - if l.legend.is_none() || l.legend_fill_is_own() { + if l.legend.is_none() || !g.picks_legend_fill() { return None; } let series: usize = g.series_paints.iter().map(|s| usize::from(s.fill.is_some()) + usize::from(s.stroke.is_some())).sum(); @@ -764,26 +765,35 @@ mod tests { #[test] fn the_default_black_legend_fill_is_left_as_it_is() { - let (mut d, a, _) = doc(); - let black = || crate::CharStyle { fill: Paint::solid(Color::BLACK), size: 11.0, ..Default::default() }; - let spec = crate::graph::GraphSpec { - series_paints: vec![crate::SeriesPaint { fill: Some(Paint::solid(Color::BLACK)), ..Default::default() }], - label_styles: crate::graph::LabelStyles { values: Some(black()), legend: Some(black()), ..Default::default() }, - ..Default::default() - }; - let blacks = |d: &Document| { - let mut seen = 0; - d.visit_paints(&mut |p| seen += usize::from(*p == Paint::solid(Color::BLACK))); - seen - }; - let before = blacks(&d); - d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec)); - assert_eq!(blacks(&d) - before, 2, "the series fill and the value labels' fill are visited"); - let mut link_black = |c: &mut Color, s: &mut Option<String>, _: &mut f32| *c == Color::BLACK && s.replace("Black".into()).is_none(); - assert_eq!(d.map_solid_paints_in(&[a], &mut link_black), 2, "the series fill and the value labels' fill"); - let styles = d.node(a).unwrap().extras().graph.clone().unwrap().label_styles; - assert_eq!(styles.legend.unwrap().fill, Paint::solid(Color::BLACK), "not linked"); - assert_eq!(styles.values.unwrap().fill, linked(Color::BLACK, "Black")); + use crate::graph::GraphKind; + // Only a pie with Legends in Wedges picks its legend labels' fill; the other graphs draw the kept black. + for (kind, in_wedges, picks) in [(GraphKind::Pie, true, true), (GraphKind::Pie, false, false), (GraphKind::Column, true, false)] { + let (mut d, a, _) = doc(); + let black = || crate::CharStyle { fill: Paint::solid(Color::BLACK), size: 11.0, ..Default::default() }; + let spec = crate::graph::GraphSpec { + kind, + legend: true, + pie_legend_in_wedges: in_wedges, + series_paints: vec![crate::SeriesPaint { fill: Some(Paint::solid(Color::BLACK)), ..Default::default() }], + label_styles: crate::graph::LabelStyles { values: Some(black()), legend: Some(black()), ..Default::default() }, + ..Default::default() + }; + let blacks = |d: &Document| { + let mut seen = 0; + d.visit_paints(&mut |p| seen += usize::from(*p == Paint::solid(Color::BLACK))); + seen + }; + let before = blacks(&d); + d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec)); + let want = if picks { 2 } else { 3 }; + assert_eq!(blacks(&d) - before, want, "{kind:?}, in wedges {in_wedges}: the series, value labels' and legend fills visited"); + let mut link_black = |c: &mut Color, s: &mut Option<String>, _: &mut f32| *c == Color::BLACK && s.replace("Black".into()).is_none(); + assert_eq!(d.map_solid_paints_in(&[a], &mut link_black), want, "{kind:?}, in wedges {in_wedges}"); + let styles = d.node(a).unwrap().extras().graph.clone().unwrap().label_styles; + let legend = if picks { Paint::solid(Color::BLACK) } else { linked(Color::BLACK, "Black") }; + assert_eq!(styles.legend.unwrap().fill, legend, "{kind:?}, in wedges {in_wedges}: linked unless the pie picks the fill"); + assert_eq!(styles.values.unwrap().fill, linked(Color::BLACK, "Black")); + } } #[test] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 21dc2ea167..5f6d602ad5 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -886,9 +886,9 @@ fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Ve } /// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with -/// Legends in Wedges whose legend labels keep no fill of their own ([`vectorcraft_doc::LabelStyles::legend_fill_is_own`])? +/// Legends in Wedges whose legend labels keep no fill of their own ([`GraphSpec::picks_legend_fill`])? pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool { - g.kind == GraphKind::Pie && g.legend && g.pie_legend_in_wedges && !g.label_styles.legend_fill_is_own() + g.picks_legend_fill() } /// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index b7ee6fe77e..7eda9804df 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -6409,6 +6409,27 @@ fn a_color_mode_change_keeps_the_default_wedge_legend_fill() { assert!(spec(&s, id).label_styles.legend_fill_set, "the converted red is still the labels' own"); } +/// The default black legend fill of a graph that keeps its legend beside the marks stays the default through a color +/// mode change, so turning on Legends in Wedges later still draws the labels on dark wedges white. +#[test] +fn a_color_mode_change_keeps_the_default_legend_fill_of_other_graphs() { + // A pie made here keeps its legend beside the pies. + for kind in ["column", "pie"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, kind, ",a,b\nP1,1,2"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 11})).unwrap(); + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let before = spec(&s, id).label_styles; + assert_eq!(before.legend.as_ref().map(|st| st.fill.clone()), Some(black.clone()), "{kind}: {before:?}"); + s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); + let after = spec(&s, id).label_styles; + assert_eq!(after.legend.as_ref().map(|st| st.fill.clone()), Some(black), "{kind}: {after:?}"); + assert!(!after.legend_fill_set && !after.legend_fill_is_own(), "{kind}: still the default: {after:?}"); + } +} + #[test] fn labels_are_told_apart_by_where_they_come_not_by_their_text() { let mut s = Session::new(); From 46c9f922c216c0f33deaf4ede1427cff5eaa9baa Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:16:02 -0800 Subject: [PATCH 71/89] Type: a document draws only its own composite font definitions While a document's composite fonts are lent, type naming a composite font the document does not define is set as in a missing font, even when the front document or the document a placed document sits in defines one of that name. A document that defines no composite font gets an empty scope too, so a document in the back, a tiled window, a thumbnail or an export made while another document is in front no longer borrows the front document's definitions. A temporary document drawn while a document draws keeps that document's fonts, and type laid out with nothing lent still uses the registered ones. - FontDb::composite_font looks only in the latest loan of the database. - lend_composite_fonts lends an empty set at the top level when the database holds composite fonts; lend_document_composite_fonts also lends one inside another loan, for placed documents, back-document measuring and threaded reflow on open. - A loan that leaves out a registered font gets its own generation, so layouts made with that font are not reused under it. --- crates/engine/src/cmd/compositefonts/tests.rs | 19 +++- crates/engine/src/cmd/fileio/load.rs | 5 +- crates/engine/src/session/documents.rs | 10 +- crates/render/src/placed_document.rs | 5 +- crates/render/src/tests/fontchange.rs | 51 +++++++++- crates/text/src/fontdb/composite.rs | 96 +++++++++++-------- crates/text/src/tests/composite.rs | 66 +++++++++++++ docs/target-app-parity.md | 1 + 8 files changed, 200 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index cac6f4bdcb..da310a89b9 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -671,10 +671,11 @@ fn a_document_replaced_in_the_back_is_measured_before_it_comes_to_the_front() { } /// Type a document replaced in the back sets in a composite font that only the front document -/// defines is measured in that font while the front one is registered, and again, as missing, when -/// the replaced document comes to the front. +/// defines is measured as missing, though the front one is registered, also when the replaced +/// document defines no composite font, and still as missing when it comes to the front. #[test] -fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() { +fn a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing() { + let db = vectorcraft_text::FontDb::global(); let mut s = session(); let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Lacked"})).unwrap()["id"].as_u64().unwrap(); let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); @@ -682,8 +683,16 @@ fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() { s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Lacked")]})).unwrap(); assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); let back = text_in(&s, 0, id); - let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &back).bounds; - assert_eq!(back.cached_bounds, Some(expected)); + assert!(s.documents()[0].doc.composite_fonts.is_empty()); + let in_front = vectorcraft_text::layout(db, &back).bounds; + // The same type set in a family nothing defines. + let mut nowhere = back.clone(); + for r in &mut nowhere.runs { + r.style.font_family = "No Such Family CF Lacked".into(); + } + let missing = vectorcraft_text::layout(db, &nowhere).bounds; + assert!((in_front.width() - missing.width()).abs() > 1e-6, "the front document's font draws otherwise"); + assert_eq!(back.cached_bounds, Some(missing)); assert!(s.set_active(0)); assert!(vectorcraft_text::FontDb::global().composite_font("CF Lacked").is_none()); let actual = text(&s, id); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index bb9dc3711a..1a3c4c6e2c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -550,9 +550,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> // Optical Margin Alignment setting and writing direction. When that changes the direction of a // frame of a restored document, the story flows through that frame's thread again. Threaded // frames keep their size: Auto Size is off for them. The story is laid out in the document's - // own composite fonts. + // own composite fonts, and type naming one the document does not define flows as in a missing + // font, also when the document defines none. let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() }; - let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts); + let lent = vectorcraft_text::FontDb::global().lend_document_composite_fonts(&fonts); if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a87b47be0e..83e6368f8b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -415,14 +415,16 @@ impl Session { /// Measure the type not measured yet in open document `index`, which is in the back, in its own /// composite fonts, lent for the measuring, so its type has the bounds it will have when the - /// document comes to the front. The front document stays the one whose composite fonts are - /// registered. The names of the document's composite fonts its type was measured in are kept, - /// so its coming to the front does not measure that type again. + /// document comes to the front: type naming a composite font the document does not define is + /// measured as in a missing font, also when the front document defines one of that name, and + /// also when the document defines none. The front document stays the one whose composite fonts + /// are registered. The names of the document's composite fonts its type was measured in are + /// kept, so its coming to the front does not measure that type again. fn measure_back_document(&mut self, index: usize) { let Some(st) = self.docs.get(index) else { return }; let fonts = st.doc.composite_fonts.clone(); let db = vectorcraft_text::FontDb::global(); - let lent = db.lend_composite_fonts(&fonts); + let lent = db.lend_document_composite_fonts(&fonts); // A name another loan holds with a different definition is not lent: type set in it is // measured again when the document comes to the front. let own: HashSet<String> = diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index 6daf192fc4..7f9453fda5 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -425,8 +425,9 @@ impl Renderer { if !k.is_finite() || k <= 1e-12 { return; } - // The art in its own coordinates, with its own resources (its composite fonts included). - let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&e.doc.composite_fonts); + // The art in its own coordinates, with its own resources (its composite fonts included, and + // none of the host document's). + let _lent = vectorcraft_text::FontDb::global().lend_document_composite_fonts(&e.doc.composite_fonts); let symbols = e.doc.symbol_boxes().quick(); let visible = m.inverse().transform_rect_bbox(f.visible); let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f }; diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 15389cd805..27e1e7e1e9 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -209,7 +209,7 @@ fn a_documents_own_composite_font_draws_without_being_registered() { } /// A placed document draws its type in its own definition of a composite font that the document -/// placing it defines differently. +/// placing it defines differently, and as missing in one only the document placing it defines. #[test] fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts, ImageBlob, LinkInfo, PlacedDocument}; @@ -260,6 +260,55 @@ fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { assert!(own == page(&plain, plain_id, "fontchange-placed-twice-plain", vec![]), "drawn in the placed document's definition"); assert!(own != page(&inter, inter_id, "fontchange-placed-twice-inter", vec![]), "not in the page's"); assert!(vectorcraft_text::FontDb::global().composite_font(NAME).is_none()); + // A placed document that defines no composite font draws type naming the page's as missing. + let (undefined, undefined_id) = typed(NAME); + let (missing, missing_id) = typed("CF Placed Nowhere"); + let borrowed = page(&undefined, undefined_id, "fontchange-placed-undefined", vec![definition("Inter")]); + assert!(borrowed == page(&missing, missing_id, "fontchange-placed-missing", vec![]), "drawn as in a missing font"); + assert!(borrowed != page(&inter, inter_id, "fontchange-placed-undefined-inter", vec![]), "not in the page's definition"); +} + +/// A document that defines no composite font, drawn while another document's composite font of +/// the name its type is set in is registered (that document in front), draws that type as in a +/// missing font: in a render, an ink render, a thumbnail and a node render. +#[test] +fn a_document_draws_type_in_another_documents_composite_font_as_missing() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts}; + let _adding = adding_fonts(); + const NAME: &str = "CF Front Only"; + let db = vectorcraft_text::FontDb::global(); + let typed = |family: &str| { + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style)))); + let l = d.layers[0].id; + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) + }; + let (back, id) = typed(NAME); + let (missing, missing_id) = typed("CF Front Nowhere"); + let (plain, plain_id) = typed("JetBrains Mono"); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let region = Rect::new(0.0, 0.0, 300.0, 150.0); + let draw = |d: &Document, id: NodeId| { + let n = d.node(id).unwrap().clone(); + ( + Renderer::new().render(d, 300, 150, Affine::IDENTITY, &opts).pixels, + Renderer::new().render_region_inks(d, region, 1.0, &opts), + Renderer::new().render_thumbnail(d, id, 64).map(|r| r.pixels), + Renderer::new().render_node(d, &std::sync::Arc::new(n), 300, 150, Affine::IDENTITY).map(|r| r.pixels), + ) + }; + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new("JetBrains Mono", "Regular"); + db.set_composite_font(&CompositeFont { name: NAME.into(), parts }); + let (drawn, as_missing, in_front) = (draw(&back, id), draw(&missing, missing_id), draw(&plain, plain_id)); + db.remove_composite_font(NAME); + assert!(drawn.0 != in_front.0, "not in the front document's definition"); + assert!(drawn.0 == as_missing.0, "rendered"); + assert!(drawn.1 == as_missing.1, "rendered as ink amounts"); + assert!(drawn.2 == as_missing.2, "thumbnail"); + assert!(drawn.3 == as_missing.3, "node"); } /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index 16e98038fa..79a608788b 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -19,7 +19,7 @@ type Resolved = Option<(Arc<FontFace>, FontMatch)>; thread_local! { /// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the - /// latest loan last. + /// latest loan last. Only the latest loan of a database is looked in. static LENT: RefCell<Vec<Loan>> = const { RefCell::new(Vec::new()) }; } @@ -34,8 +34,8 @@ struct Loan { number: u64, /// The fonts, by lowercased name. fonts: HashMap<String, Arc<CompositeFont>>, - /// The database's generation when they were lent, when each was then the font the database - /// holds by its name. + /// The database's generation when they were lent, when they were then exactly the fonts the + /// database holds: each the font it holds by its name, and none it holds left out. same_at: Option<u64>, /// A number for the definitions lent: loans of the same definitions have the same one. print: u64, @@ -67,8 +67,8 @@ pub struct CompositeCoverage { impl FontDb { /// Make `font` available by its name, in place of a composite font of that name (any case). /// The font menus and anything else that lists fonts change only when the definition does. - /// Whether it did. A font of that name a guard lends ([`FontDb::lend_composite_fonts`]) stays - /// in use on the guard's thread. + /// Whether it did. While a guard lends composite fonts ([`FontDb::lend_composite_fonts`]), the + /// guard's thread uses the fonts it lends and not this one. pub fn set_composite_font(&self, font: &CompositeFont) -> bool { let key = font.name.to_lowercase(); { @@ -91,15 +91,13 @@ impl FontDb { removed } - /// The composite font named `name` (any case): the one the latest guard of this thread that - /// lends that name lends ([`FontDb::lend_composite_fonts`]), else the one the database holds. + /// The composite font named `name` (any case). While a guard of this thread lends a document's + /// composite fonts ([`FontDb::lend_composite_fonts`]), the one the latest such guard lends, and + /// `None` when that document does not define the name, also when the database or an earlier + /// guard holds one of that name. Otherwise the one the database holds. pub fn composite_font(&self, name: &str) -> Option<Arc<CompositeFont>> { if self.lends() { - let key = name.to_lowercase(); - if let Some(f) = self.lent_font(&key) { - return Some(f); - } - return self.composites.read().unwrap_or_else(|e| e.into_inner()).get(&key).cloned(); + return self.lent_font(&name.to_lowercase()); } let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); if map.is_empty() { @@ -113,30 +111,27 @@ impl FontDb { LENT.try_with(|l| l.borrow().iter().any(|loan| loan.db == self.id)).unwrap_or(false) } - /// The composite font named `key` (lowercased) that the latest guard of this thread lending that - /// name lends. + /// The composite font named `key` (lowercased) that the latest guard of this thread lending + /// composite fonts of this database lends. fn lent_font(&self, key: &str) -> Option<Arc<CompositeFont>> { - LENT.try_with(|l| l.borrow().iter().rev().filter(|loan| loan.db == self.id).find_map(|loan| loan.fonts.get(key).cloned())).ok().flatten() + LENT.try_with(|l| l.borrow().iter().rev().find(|loan| loan.db == self.id).and_then(|loan| loan.fonts.get(key).cloned())).ok().flatten() } - /// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while each - /// font the guards of this thread lend is the one the database held by its name when it was lent - /// and the database has not changed since, else a number made from `g` and the definitions - /// lent, so what was laid out in one document's definitions is not taken for another's. + /// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while no + /// guard of this thread lends composite fonts of this database, or while the latest one lends + /// exactly the fonts the database held when it was lent and the database has not changed since, + /// else a number made from `g` and the definitions lent, so what was laid out in one document's + /// definitions is not taken for another's. pub(super) fn lent_generation(&self, g: u64) -> u64 { - LENT.try_with(|l| { - let l = l.borrow(); - let loans = l.iter().filter(|loan| loan.db == self.id); - if loans.clone().all(|loan| loan.same_at == Some(g)) { - return g; - } - let mut h = DefaultHasher::new(); - g.hash(&mut h); - for loan in loans { + LENT.try_with(|l| match l.borrow().iter().rev().find(|loan| loan.db == self.id) { + Some(loan) if loan.same_at != Some(g) => { + let mut h = DefaultHasher::new(); + g.hash(&mut h); loan.print.hash(&mut h); + // The database's generations count up from 0 and never reach the top bit. + h.finish() | 1 << 63 } - // The database's generations count up from 0 and never reach the top bit. - h.finish() | 1 << 63 + _ => g, }) .unwrap_or(g) } @@ -245,20 +240,41 @@ impl FontDb { } } - /// Make each of the composite fonts `fonts` (a document's) available on this thread while the - /// returned guard lives, in place of a composite font of the same name that the database holds - /// or that an earlier guard of this thread lends. An app's session registers the composite fonts - /// of its front document; with the guard, a document exported without a session, one that is not - /// in front, or one placed in another document draws its type in its own, also while another - /// thread draws another document that defines the same name differently, or the session - /// registers another document's. Of fonts that share a name (in any case), the first is lent. + /// Make the composite fonts `fonts` (a document's) the only ones available on this thread while + /// the returned guard lives: type naming a composite font they do not define is set as in a + /// missing font, also when the database or an earlier guard of this thread holds one of that + /// name. An app's session registers the composite fonts of its front document; with the guard, a + /// document exported without a session, one that is not in front, or one placed in another + /// document draws its type in its own, also while another thread draws another document that + /// defines the same name differently, or the session registers another document's. Of fonts + /// that share a name (in any case), the first is lent. When none of `fonts` is usable and no + /// guard of this thread lends composite fonts of the database, the guard lends none: type naming + /// any composite font is set as in a missing font while it lives. When none is usable inside + /// another guard (a temporary document drawn while a document draws), nothing is lent and that + /// guard's fonts stay in use; [`FontDb::lend_document_composite_fonts`] lends none there too. /// Other threads, and the fonts the database holds and lists, are left as they are. The guard - /// stays on its thread and ends its loan when it drops, also when the thread unwinds. + /// stays on its thread and ends its loan when it drops, also when the thread unwinds. With no + /// guard (type laid out on its own), the fonts the database holds are used. pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { + self.lend(fonts, false) + } + + /// [`FontDb::lend_composite_fonts`] for a document that is drawn or measured as itself, also + /// inside another document's guard (a document placed in another): when it defines no usable + /// composite font, type naming any composite font is set as in a missing font while the guard + /// lives. + pub fn lend_document_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { + self.lend(fonts, true) + } + + /// Lend the usable fonts of `fonts`. With none, lend an empty set when `always` or when no + /// guard of this thread lends fonts of this database (and the database holds some), else + /// nothing. + fn lend(&self, fonts: &[CompositeFont], always: bool) -> LentCompositeFonts<'_> { // A font a document can't use (its name an installed family's, or a value out of range) // is not lent: an app leaves it out when it opens the document. let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect(); - if usable.is_empty() { + if usable.is_empty() && !always && (self.lends() || self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty()) { return LentCompositeFonts { number: None, thread: PhantomData }; } let mut lent: HashMap<String, Arc<CompositeFont>> = HashMap::with_capacity(usable.len()); @@ -281,6 +297,8 @@ impl FontDb { }; lent.insert(key, font); } + // Each lent font is the database's; with as many, none it holds is left out. + same &= lent.len() == map.len(); self.generation.load(Ordering::Relaxed) }; let number = NEXT_LOAN.fetch_add(1, Ordering::Relaxed); diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 4158d89330..0765b88aaf 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -192,6 +192,72 @@ fn nested_loans_of_one_name_use_each_documents_definition() { assert_eq!(db.generation(), plain); } +/// While one document's composite fonts are lent, a name only another document defines (the one +/// the database holds, or one an outer guard lends) is no composite font: type set in it lays out +/// as in a missing font, also after it was laid out in that definition with nothing lent. +#[test] +fn a_lent_document_does_not_use_another_documents_composite_font() { + let db = FontDb::with_font_dirs(vec![]); + let (a, b) = (roman_at("CF Only A", 50.0), roman_at("CF Only B", 50.0)); + // The session registered B's font, and A's (the same definition) as well. + db.set_composite_font(&a); + db.set_composite_font(&b); + let half = advance(&db, "JetBrains Mono", 10.0); + assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9); + let missing = advance(&db, "CF Nowhere", 20.0); + assert!((missing - half).abs() > 1e-6, "the missing font draws otherwise"); + // Lending looks the name up among the installed families, which catalogs them the first time. + assert!(!db.has_installed_family("CF Only A")); + let plain = db.generation(); + { + let _a = db.lend_composite_fonts(std::slice::from_ref(&a)); + assert!(db.composite_font("CF Only A").is_some()); + assert!(db.composite_font("cf only b").is_none()); + assert!(db.composite_fonts_for("CF Only B", "A").is_none()); + assert_ne!(db.generation(), plain, "type laid out with B's font is not reused"); + assert!((advance(&db, "CF Only B", 20.0) - missing).abs() < 1e-9); + assert!((advance(&db, "CF Only A", 20.0) - half).abs() < 1e-9); + // A document placed in A that defines B's font sees only its own. + let _b = db.lend_composite_fonts(std::slice::from_ref(&b)); + assert!(db.composite_font("CF Only A").is_none()); + assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9); + } + assert_eq!(db.generation(), plain); + assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9); +} + +/// A document that defines no composite font, drawn or lent as a document, uses none of the +/// database's. A temporary document that defines none, drawn inside another document's guard, +/// keeps that guard's fonts; with no guard at all, the database's stay in use. +#[test] +fn a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged() { + let db = FontDb::with_font_dirs(vec![]); + let (a, b) = (roman_at("CF Held A", 50.0), roman_at("CF Held B", 50.0)); + db.set_composite_font(&b); + let half = advance(&db, "JetBrains Mono", 10.0); + let missing = advance(&db, "CF Nowhere Else", 20.0); + let plain = db.generation(); + for lend in [FontDb::lend_composite_fonts, FontDb::lend_document_composite_fonts] { + let _none = lend(&db, &[]); + assert!(db.composite_font("CF Held B").is_none()); + assert_ne!(db.generation(), plain); + assert!((advance(&db, "CF Held B", 20.0) - missing).abs() < 1e-9); + } + { + let _a = db.lend_composite_fonts(std::slice::from_ref(&a)); + // A temporary document drawn inside it lends nothing and keeps A's fonts. + let _preview = db.lend_composite_fonts(&[]); + assert!(db.composite_font("CF Held A").is_some()); + assert!(db.composite_font("CF Held B").is_none()); + // A document placed in it that defines none uses none of A's. + let _placed = db.lend_document_composite_fonts(&[]); + assert!(db.composite_font("CF Held A").is_none()); + } + assert_eq!(db.generation(), plain); + assert!(db.composite_font("CF Held B").is_some()); + assert!((advance(&db, "CF Held B", 20.0) - half).abs() < 1e-9); +} + /// Two threads that lend different definitions of one name at once each lay out in their own. #[test] fn threads_lending_one_name_at_once_use_their_own_definitions() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..b267eeb16c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -682,6 +682,7 @@ since 2026-10-01. - **OpenType SVG colour fonts:** each glyph's SVG document is read by the SVG importer (registered by the engine) into vector art in the run's colours, then drawn, exported and outlined as COLR glyphs are; PDF real text writes them as text that draws nothing and draws their art where they sit. Measured by `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`. Scores unchanged (estimated). - **Create Outlines keeps the type object's own appearance:** Type › Create Outlines and Object › Expand put the type object's own fills and strokes (the Appearance panel's items around its Characters row) on paths of the whole outline, below or above the glyph paths as that row places them, with one gradient across every glyph, so the outlines draw what the type drew; `object.expand.info` reports those strokes, pattern fills and gradient fills. Outlined characters keep their stroke and fill (the help page, <https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=337>); a type object's appearance is separate from its characters' (the help page, <https://helpx.adobe.com/illustrator/using/appearance-attributes.html>); where the object's items go is VectorCraft's own rule. Measured by `tests::pathops::create_outlines_keeps_the_objects_own_fills_and_strokes`, `tests::objexpand::create_outlines_draws_the_type_objects_own_paint`, `tests::objexpand::type_objects_own_stroke_pattern_and_gradient_are_reported`, `tests::objexpand::type_without_an_outline_reports_no_own_stroke_or_fill`, `tests::objexpand::a_stroke_under_an_unpainted_stroke_is_reported_and_outlined` and `tests::objexpand::expand_keeps_the_type_objects_own_paint`. Scores unchanged (estimated). - **Composite fonts that two documents define differently:** when two open documents each define a composite font (Type › Composite Fonts…) by the same name with different parts, each document draws and exports its type in its own definition: in a tiled or floating window drawn while the front document draws, when it is placed in a document that defines the name differently, and in an export made while another document is in front. Evidence: `tests::composite::nested_loans_of_one_name_use_each_documents_definition` and `threads_lending_one_name_at_once_use_their_own_definitions` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` (`crates/render`). Scores unchanged (estimated). +- **Composite fonts only another document defines:** a document draws, measures and exports its type only in the composite fonts it defines itself. Type naming a composite font that only another open document, or the document a placed document sits in, defines is set as in a missing font, also when the document defines no composite font: in a window, a thumbnail or an export of a document in the back, in a file being opened and in a placed document. A temporary document drawn while a document draws keeps that document's composite fonts. Evidence: `tests::composite::a_lent_document_does_not_use_another_documents_composite_font` and `a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` and `a_document_draws_type_in_another_documents_composite_font_as_missing` (`crates/render`), `a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing` (`crates/engine`). Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). From be44f10b6a2d2f26feb132c0c6bdebc4feb36345 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:21:58 -0800 Subject: [PATCH 72/89] Effects: a clipping path's members count toward the member limit of a group's effects When a group's geometry effects evaluate the members of the group, a clip group's clipping path takes the geometry effects in it first. A clipping path that is a group, layer or compound path had each of its members reshaped at every depth without counting them, so a large clipping path was reshaped past the SYMBOL_ART_MAX_NODES members that members_as_drawn documents. Each member of a clipping path now counts as one visited member. Once the limit is reached, that member and the members after it keep their effects, as the other members past the limit do. The canvas, the export bake and clipping type are unchanged: they do not count members. --- crates/effects/src/clip.rs | 26 ++++++++++++++++++-------- crates/effects/src/group.rs | 5 +++-- crates/effects/src/group/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 60cc3f2845..bcb2dd0995 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::{BezPath, FillRule, PathData}; /// fills and strokes: the region is then the union of the paths they give, as an export bakes them. /// `None` when there is nothing to clip by (the clipped art is then hidden). pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { - match clip_effects_applied(clip, true) { + match clip_effects_applied(clip, true, || true) { Some(m) => m.clip_outline(Some(&outline_text), &unite), None => clip.clip_outline(Some(&outline_text), &unite), } @@ -31,14 +31,21 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { /// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a /// geometry effect. Effects on the clip group or around it then reshape the result, after its own. pub(crate) fn with_clip_effects_applied(n: &Node) -> Option<Node> { - let paint = n.clip_paint(); - clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none()) + with_clip_effects_applied_while(n, || true) } -/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of -/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of -/// `n`'s own fills and strokes are left out. -fn clip_effects_applied(n: &Node, items: bool) -> Option<Node> { +/// [`with_clip_effects_applied`] that calls `take` before it visits each member of a group, layer +/// or compound path in `n`, at every depth: once `take` returns `false`, that member and the +/// members after it stay as they are, with their effects. +pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option<Node> { + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) +} + +/// [`with_clip_effects_applied_while`]; with `items` unset (a clipping path that paints, and the +/// paths of a compound path, which draw with the compound path's fills and strokes), the geometry +/// effects of `n`'s own fills and strokes are left out. +fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option<Node> { let mut m: Option<Node> = None; let members: &[std::sync::Arc<Node>] = match &n.kind { NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(), @@ -53,7 +60,10 @@ fn clip_effects_applied(n: &Node, items: bool) -> Option<Node> { // The paths of a compound path draw with the compound path's fills and strokes, and stay paths. let compound = matches!(n.kind, NodeKind::Compound { .. }); for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) { - if let Some(r) = clip_effects_applied(c, items && !compound) + if !take() { + break; + } + if let Some(r) = clip_effects_applied(c, items && !compound, take) && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) { *slot = std::sync::Arc::new(r); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 20b4f982a3..34cc98e3bb 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -904,7 +904,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> { /// depth of its groups and layers, by the art it draws ([`evaluate_container`], /// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members /// draw. A clip group's clipping path only takes the geometry effects in it into its geometry -/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects +/// ([`crate::clip::with_clip_effects_applied_while`]), and the members of a clipping group or +/// compound path count toward the limit below. Images, symbol instances and the other objects /// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D /// effect with everything in it. Calls nested in one another, through the /// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members @@ -917,7 +918,7 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { // The clipping path's own geometry effects shape it before the effects around it do. if clip == 1 && let Some(first) = ch.first_mut() - && let Some(applied) = crate::clip::with_clip_effects_applied(first) + && let Some(applied) = crate::clip::with_clip_effects_applied_while(first, DrawnFrame::take_member) { *first = Arc::new(applied); } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 877528d0bb..8b8fda5727 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -849,6 +849,28 @@ fn nested_groups_with_effects_count_toward_the_depth_limit() { assert!(deepest.is_some_and(|d| crate::reshape::geometry_effects(&d).len() == 1), "the deepest member keeps its effect"); } +/// The members of a clipping path count toward the member limit: with room for two more members, +/// the third member of a clipping group keeps its own effect as it is. +#[test] +fn clipping_path_members_past_the_member_limit_stay_as_they_are() { + let member = |id: u64| { + let mut m = with_effect(square_at_100(), offset_10()); + m.id = NodeId(id); + Arc::new(m) + }; + let clipping = Node::group(NodeId(20), vec![member(2), member(3), member(4)]); + let mut clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clipping), Arc::new(square_at_100())], clip: true }); + // An enclosing call that has visited all the members it may but two. + let _outer = DrawnFrame::enter().unwrap(); + DRAWN.with(|d| d.set((1, crate::SYMBOL_ART_MAX_NODES - 2))); + members_as_drawn(None, &mut clipped); + let clipping = clipped.children().unwrap()[0].clone(); + let effects: Vec<usize> = clipping.children().unwrap().iter().map(|m| crate::reshape::geometry_effects(m).len()).collect(); + assert_eq!(effects, [0, 0, 1], "the first two members take their Offset Path, the third keeps it"); + let widths: Vec<f64> = clipping.children().unwrap().iter().map(|m| m.geometric_bounds().unwrap().width()).collect(); + assert!((widths[0] - 120.0).abs() < 0.5 && (widths[1] - 120.0).abs() < 0.5 && (widths[2] - 100.0).abs() < 0.5, "{widths:?}"); +} + /// An image or a symbol instance that a group's Transform effect moves takes the placed gradients /// of its own fills along, as a path does, and each copy takes them as many times as it takes the /// transform. From 335ded4b0a05743de9c9bdb1550227ec5d29c3be Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:27:23 -0800 Subject: [PATCH 73/89] Learn: a click on the shown tab runs no command A click on the For you or Skills tab the panel already shows ran learn.tab although the view did not change. The tab buttons now run it only for the other tab. --- crates/ui-egui/src/panels/learn.rs | 2 +- crates/ui-egui/src/panels/learn/tests.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index bb9b68e86a..82e139e25c 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -60,7 +60,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut chosen = None; ui.horizontal(|ui| { for ((tab, name), label) in TABS.into_iter().zip([tl!("For you"), tl!("Skills")]) { - if ui.selectable_label(app.ui.learn.tab == tab, label).clicked() { + if ui.selectable_label(app.ui.learn.tab == tab, label).clicked() && app.ui.learn.tab != tab { chosen = Some(name); } } diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index 82bd0e454d..319ca1decf 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -523,6 +523,19 @@ fn the_tabs_rows_and_back_run_their_commands() { press(&mut app, "Back", View { tab: Tab::Skills, skill: None, tutorial: None }); } +/// A click on the tab the panel already shows runs no command. +#[test] +fn a_click_on_the_shown_tab_runs_no_command() { + let mut app = app(); + let ctx = context(); + for name in ["Skills", "For you"] { + click(&mut app, &ctx, name, show); + let commands = app.run_count; + click(&mut app, &ctx, name, show); + assert_eq!(app.run_count, commands, "{name}"); + } +} + /// `learn.tab` switches tabs and keeps what the Skills tab had open; `learn.skill` opens a skill's /// tutorials and answers the skill as `learn.list` lists it; `learn.back` goes up one level and /// keeps the view on the list of skills and on the For you tab. From 4aa2147fba2efe915d45a3c23bfda854dd095f67 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:27:52 -0800 Subject: [PATCH 74/89] Image Trace: blend settling keeps its changes within the labels' size Each band of rows (settled) and each group of strips (settled_wide) now keeps its changed labels as pixel and label pairs only until the pairs would take more memory than a copy of that part's labels, and then keeps the copy with the changes made. settled_wide splits the picture into ranges of columns instead of first listing the start of every strip. - The tests of settled and settled_wide move into cut_edge.rs, and both functions are private again. - A new test checks that the changes switch to a copy at that size and give the same labels either way. - parallel_map is imported from the crate root, as in deblock.rs and smooth.rs. --- crates/trace/src/quantize.rs | 76 +++------- crates/trace/src/quantize/cut_edge.rs | 198 +++++++++++++++++++++----- 2 files changed, 180 insertions(+), 94 deletions(-) diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 5f3a4e7081..e89f3eda3b 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -1,7 +1,7 @@ //! Colour reduction and noise removal. Colours are compared in Oklab, where equal distances look //! equally different. -use crate::{Mode, Palette, Raster, TraceParams}; +use crate::{Mode, Palette, Raster, TraceParams, parallel_map}; mod cut_edge; mod denoise; @@ -10,8 +10,6 @@ mod rare; use cut_edge::{choosing_pixels, compact, settled, settled_wide, settles_wide}; pub(crate) use cut_edge::{continuous_tone, settle_cut_edges}; -#[cfg(test)] -use cut_edge::{settled_by, settled_wide_by}; pub use denoise::denoise; pub(crate) use denoise::denoise_keeping; pub use palette::FULL_TONE_MAX; @@ -250,7 +248,7 @@ fn over_rows<R: Send>(w: usize, h: usize, per_share: usize, f: impl Fn(std::ops: if bands.len() <= 1 { return bands.into_iter().flat_map(&f).collect(); } - crate::layers::parallel_map(&bands, |rows| f(rows.clone())).into_iter().flatten().collect() + parallel_map(&bands, |rows| f(rows.clone())).into_iter().flatten().collect() } /// Whether the palette is made from the picture's own colours alone (`own` of `all` opaque @@ -361,60 +359,27 @@ fn merge_mixed(q: Quantized, w: usize, h: usize, keep: &[[u8; 3]]) -> Quantized mod tests { use super::*; - /// Rings of four colors around a point off the center, blended over three pixels at each edge, - /// with a little noise, and a band of half-transparent pixels along the bottom. - fn rings(w: u32, h: u32) -> Raster { - let colors = [[200.0, 30.0, 40.0], [20.0, 60.0, 180.0], [240.0, 210.0, 50.0], [25.0, 25.0, 25.0]]; - Raster::from_fn(w, h, |x, y| { - let r = (f64::from(x) - 0.4 * f64::from(w)).hypot(f64::from(y) - 0.55 * f64::from(h)) / 9.0; - let (ring, t) = (r.floor() as usize, ((r.fract() - 0.8) / 0.2).clamp(0.0, 1.0)); - let (a, b) = (colors[ring % 4], colors[(ring + 1) % 4]); - let noise = f64::from((x * 7 + y * 13) % 5) - 2.0; - let c = [0, 1, 2].map(|k| (a[k] + t * (b[k] - a[k]) + noise).clamp(0.0, 255.0) as u8); - [c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }] - }) - } - - /// `img` labeled with the palette color nearest to each pixel (none for a transparent one). - fn nearest_labels(img: &Raster, palette: &[[u8; 3]]) -> Quantized { - let labels = img - .rgba - .as_chunks::<4>() - .0 - .iter() - .map(|p| { - let d = |c: &[u8; 3]| (0..3).map(|k| (i32::from(p[k]) - i32::from(c[k])).pow(2)).sum::<i32>(); - (0..palette.len()).min_by_key(|&i| d(&palette[i])).map_or(TRANSPARENT, |i| i as u16) - }) - .collect(); - Quantized { labels, palette: palette.to_vec() } - } - #[test] - fn passes_over_every_pixel_give_the_same_result_however_the_picture_is_shared_out() { - // Odd sizes, so that the last band of rows and the last strip of columns are short. - let img = rings(173, 97); - let all = img.width as usize * img.height as usize; - // One row (or one strip) per share, a few rows per share, and the whole picture as one. - let shares = [1, 3 * 173 + 5, all]; + fn blend_pixels_are_found_the_same_however_the_picture_is_shared_out() { + // Slanted stripes of red and blue, blended over three pixels at each edge, with a little + // noise and a band of half-transparent pixels along the bottom. Odd sizes, so that the last + // band of rows is short. + let (w, h) = (173u32, 97u32); + let (red, blue) = ([200.0, 30.0, 40.0], [20.0, 60.0, 180.0]); + let img = Raster::from_fn(w, h, |x, y| { + let s = (x + y / 2) % 24; + let t = f64::from(s.saturating_sub(9).min(3)) / 3.0 - f64::from(s.saturating_sub(21).min(3)) / 3.0; + let noise = f64::from((x * 7 + y * 13) % 5) - 2.0; + let c = [0, 1, 2].map(|k| (red[k] + t * (blue[k] - red[k]) + noise).clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }] + }); + let all = w as usize * h as usize; + // One row per share, a few rows per share, and the whole picture as one. let opaque: Vec<bool> = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect(); - let own: Vec<Vec<bool>> = shares.iter().map(|&s| own_colours_among_by(&img, &opaque, s)).collect(); + let own: Vec<Vec<bool>> = [1, 3 * 173 + 5, all].iter().map(|&s| own_colours_among_by(&img, &opaque, s)).collect(); assert_eq!(own[0].len(), all); assert!(own[0].iter().any(|&o| !o), "the edges have blend pixels"); assert!(own.iter().all(|o| *o == own[0])); - // Two colors for each blend, and an olive that the blends lie nearer to than to their sides. - let palette = [[200, 30, 40], [20, 60, 180], [240, 210, 50], [25, 25, 25], [120, 120, 60]]; - let q = nearest_labels(&img, &palette); - let own = &own[0]; - for (name, settle) in [ - ("settled", &(|s| settled_by(q.clone(), &img, own, s)) as &dyn Fn(usize) -> Quantized), - ("settled_wide", &|s| settled_wide_by(q.clone(), &img, own, false, s)), - ("settled_wide gray", &|s| settled_wide_by(q.clone(), &img, own, true, s)), - ] { - let out: Vec<Quantized> = shares.iter().map(|&s| settle(s)).collect(); - assert_ne!(out[0], q, "{name} settles some blend pixels"); - assert!(out.iter().all(|o| *o == out[0]), "{name}"); - } } #[test] @@ -424,10 +389,5 @@ mod tests { assert!(over_rows(5, 0, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty()); let img = Raster::new(0, u32::MAX, vec![]).unwrap(); assert!(own_colours_among(&img, &[]).is_empty()); - // No strip for each of the columns of a picture with no rows. - let img = Raster::new(u32::MAX, 0, vec![]).unwrap(); - let q = Quantized { labels: vec![], palette: vec![[0, 0, 0], [255, 255, 255]] }; - assert_eq!(settled_wide_by(q.clone(), &img, &[], false, PIXELS_PER_SHARE), q); - assert_eq!(settled_by(q.clone(), &img, &[], PIXELS_PER_SHARE), q); } } diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index bc0bad05fc..2155d6d2af 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -1,6 +1,6 @@ use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab}; use super::{Mode, PIXELS_PER_SHARE, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab, over_rows}; -use crate::layers::parallel_map; +use crate::parallel_map; /// `q` with its blend pixels settled, and the colours that leaves unused dropped. /// @@ -16,19 +16,20 @@ pub(super) fn settled(q: Quantized, img: &Raster, own: &[bool]) -> Quantized { } /// [`settled`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]). -pub(super) fn settled_by(mut q: Quantized, img: &Raster, own: &[bool], per_share: usize) -> Quantized { +fn settled_by(mut q: Quantized, img: &Raster, own: &[bool], per_share: usize) -> Quantized { let (w, h) = (img.width as usize, img.height as usize); if q.labels.len() != w * h || own.len() != w * h { return q; } - // Each pixel is settled by the labels as they were before any changed, so the bands of rows - // are settled apart and the changes made afterwards. + // Each pixel is settled by the labels as they were before any label changed. Each band of rows + // keeps its changes (see [`Changes`]), and they are made once every band is done. let before = &q.labels; let color = |l: u16| q.palette.get(l as usize).map(|c| c.map(f64::from)); let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>().sqrt(); - let changes: Vec<(usize, u16)> = over_rows(w, h, per_share, |rows| { - let mut changes = vec![]; - for i in rows.start * w..rows.end * w { + let bands: Vec<(usize, Changes)> = over_rows(w, h, per_share, |rows| { + let (first, end) = (rows.start * w, rows.end * w); + let mut band = Changes::new(end - first); + for i in first..end { let mine_l = before[i]; if own[i] || mine_l == TRANSPARENT { continue; @@ -67,22 +68,73 @@ pub(super) fn settled_by(mut q: Quantized, img: &Raster, own: &[bool], per_share if let Some((off, to)) = best && off < 0.5 * dist(p, mine) { - changes.push((i, to)); + band.set(i - first, to, || before.get(first..end).unwrap_or_default().to_vec()); } } - changes + if band.is_empty() { vec![] } else { vec![(first, band)] } }); - if changes.is_empty() { + if bands.is_empty() { return q; } - for (i, to) in changes { - if let Some(l) = q.labels.get_mut(i) { - *l = to; - } + for (first, band) in bands { + band.each(|at, to| { + if let Some(l) = q.labels.get_mut(first + at) { + *l = to; + } + }); } compact(q.labels, q.palette) } +/// The labels a pass changes in one part of a picture, `len` pixels long, counting each pixel from +/// the part's first. They are kept as pairs of a pixel and its new label until the pairs would take +/// more memory than a copy of the part's labels, and as that copy, with the changes made, after +/// that. A part takes at most about as much memory as a copy of its labels either way. +struct Changes { + len: usize, + few: Vec<(usize, u16)>, + copy: Option<Vec<u16>>, +} + +impl Changes { + fn new(len: usize) -> Self { + Changes { len, few: vec![], copy: None } + } + + /// Changes the label of the pixel `at` to `to`. `labels` gives the part's labels as they were + /// before any changed, for the copy. + fn set(&mut self, at: usize, to: u16, labels: impl FnOnce() -> Vec<u16>) { + if let Some(copy) = &mut self.copy { + if let Some(l) = copy.get_mut(at) { + *l = to; + } + return; + } + self.few.push((at, to)); + if self.few.len() * size_of::<(usize, u16)>() > self.len * size_of::<u16>() { + let mut copy = labels(); + for (at, to) in std::mem::take(&mut self.few) { + if let Some(l) = copy.get_mut(at) { + *l = to; + } + } + self.copy = Some(copy); + } + } + + fn is_empty(&self) -> bool { + self.few.is_empty() && self.copy.is_none() + } + + /// `f` of each changed pixel and its new label (of every pixel of the part, once it is a copy). + fn each(self, mut f: impl FnMut(usize, u16)) { + match self.copy { + Some(copy) => copy.into_iter().enumerate().for_each(|(at, to)| f(at, to)), + None => self.few.into_iter().for_each(|(at, to)| f(at, to)), + } + } +} + /// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels /// settled by [`settled_wide`] rather than [`settled`]. The limited-color presets ask for 3 to 16 /// colors. @@ -146,36 +198,40 @@ pub(super) fn settled_wide(q: Quantized, img: &Raster, own: &[bool], gray: bool) /// [`settled_wide`], with its strips of columns worked on in groups of about `per_share` pixels /// (at least one strip), spread over the available cores (one thread on the web). -pub(super) fn settled_wide_by(mut q: Quantized, img: &Raster, own: &[bool], gray: bool, per_share: usize) -> Quantized { +fn settled_wide_by(mut q: Quantized, img: &Raster, own: &[bool], gray: bool, per_share: usize) -> Quantized { let (w, h) = (img.width as usize, img.height as usize); if q.labels.len() != w * h || own.len() != w * h || w == 0 || h == 0 { return q; } - // Each pixel is settled by the labels as they were before any changed, so the strips are - // settled apart and the changes made afterwards. Strips are grouped so that each group holds - // about `per_share` pixels. + // Each pixel is settled by the labels as they were before any label changed. Each group of + // strips keeps its changes (see [`Changes`]), and they are made once every group is done. let strip = BLEND_STRIP.min(w).max(1); let per_group = (per_share / (strip * h).max(1)).max(1); - let starts: Vec<usize> = (0..w).step_by(strip).collect(); - let groups: Vec<&[usize]> = starts.chunks(per_group).collect(); - let settle = |starts: &&[usize]| settled_wide_strips(&q, img, own, gray, starts); - let changes: Vec<(usize, u16)> = - if groups.len() <= 1 { groups.iter().flat_map(settle).collect() } else { parallel_map(&groups, settle).into_iter().flatten().collect() }; - if changes.is_empty() { + let cols = strip.saturating_mul(per_group); + let groups: Vec<std::ops::Range<usize>> = (0..w).step_by(cols).map(|x| x..x.saturating_add(cols).min(w)).collect(); + let settle = |cols: &std::ops::Range<usize>| (cols.clone(), settled_wide_strips(&q, img, own, gray, cols.clone())); + let mut settled: Vec<(std::ops::Range<usize>, Changes)> = + if groups.len() <= 1 { groups.iter().map(settle).collect() } else { parallel_map(&groups, settle) }; + settled.retain(|(_, changes)| !changes.is_empty()); + if settled.is_empty() { return q; } - for (i, to) in changes { - if let Some(l) = q.labels.get_mut(i) { - *l = to; - } + for (cols, group) in settled { + let gw = cols.len().max(1); + group.each(|at, to| { + if let Some(l) = q.labels.get_mut((at / gw) * w + cols.start + at % gw) { + *l = to; + } + }); } compact(q.labels, q.palette) } -/// The labels that [`settled_wide`] changes in the strips of [`BLEND_STRIP`] columns that start at -/// the columns `starts`, with the pixel each one is at. -fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, starts: &[usize]) -> Vec<(usize, u16)> { +/// The labels that [`settled_wide`] changes in the columns `cols` (whole strips of [`BLEND_STRIP`] +/// columns, or the picture's last columns), each pixel counted row by row across those columns. +fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, cols: std::ops::Range<usize>) -> Changes { let (w, h) = (img.width as usize, img.height as usize); + let gw = cols.len(); let before = &q.labels; let colors: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect(); let dist2 = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>(); @@ -186,7 +242,7 @@ fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, st let span = 2 * BLEND_REACH + 1; let strip = BLEND_STRIP.min(w).max(1); let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] }; - let mut changes = vec![]; + let mut settled = Changes::new(gw * h); let mut rows = vec![0u64; span * strip * words]; let mut counts = vec![0u32; q.palette.len()]; let mut window = vec![0u64; words]; @@ -231,8 +287,8 @@ fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, st }; let mut near: Vec<u16> = Vec::new(); let mut by_distance: Vec<(f64, u16)> = Vec::new(); - for (x0, y) in starts.iter().flat_map(|&x0| (0..h).map(move |y| (x0, y))) { - let x1 = (x0 + strip).min(w); + for (x0, y) in cols.clone().step_by(strip).flat_map(|x0| (0..h).map(move |y| (x0, y))) { + let x1 = (x0 + strip).min(cols.end); if y == 0 { for yy in 0..BLEND_REACH.min(h) { row_colors(yy, x0, x1, &mut rows); @@ -314,11 +370,13 @@ fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, st && !pair.contains(&mine_l) && (off2 < BETTER_FIT * dist2(p, mine) || (between && off2 < ON_THE_LINE * ON_THE_LINE * apart2)) { - changes.push((i, to)); + settled.set(y * gw + x - cols.start, to, || { + (0..h).flat_map(|yy| before.get(yy * w + cols.start..yy * w + cols.end).unwrap_or_default()).copied().collect() + }); } } } - changes + settled } /// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy @@ -708,6 +766,74 @@ fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool { mod tests { use super::*; + /// Rings of four colors around a point off the center, blended over three pixels at each edge, + /// with a little noise, and a band of half-transparent pixels along the bottom. + fn rings(w: u32, h: u32) -> Raster { + let colors = [[200.0, 30.0, 40.0], [20.0, 60.0, 180.0], [240.0, 210.0, 50.0], [25.0, 25.0, 25.0]]; + Raster::from_fn(w, h, |x, y| { + let r = (f64::from(x) - 0.4 * f64::from(w)).hypot(f64::from(y) - 0.55 * f64::from(h)) / 9.0; + let (ring, t) = (r.floor() as usize, ((r.fract() - 0.8) / 0.2).clamp(0.0, 1.0)); + let (a, b) = (colors[ring % 4], colors[(ring + 1) % 4]); + let noise = f64::from((x * 7 + y * 13) % 5) - 2.0; + let c = [0, 1, 2].map(|k| (a[k] + t * (b[k] - a[k]) + noise).clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }] + }) + } + + #[test] + fn changes_are_copied_out_once_their_pairs_would_take_more_memory_than_the_labels() { + let labels: Vec<u16> = (0..64).collect(); + let pairs = labels.len() * size_of::<u16>() / size_of::<(usize, u16)>(); + let mut changes = Changes::new(labels.len()); + for at in 0..pairs { + changes.set(at * 3, 99, || labels.clone()); + } + assert!(changes.copy.is_none()); + changes.set(63, 98, || labels.clone()); + assert!(changes.few.is_empty() && changes.copy.is_some()); + changes.set(1, 97, || labels.clone()); + let mut out = labels.clone(); + changes.each(|at, to| out[at] = to); + let mut expected = labels.clone(); + (0..pairs).for_each(|at| expected[at * 3] = 99); + (expected[63], expected[1]) = (98, 97); + assert_eq!(out, expected); + } + + #[test] + fn blend_pixels_settle_the_same_however_the_picture_is_shared_out() { + // Odd sizes, so that the last band of rows and the last strip of columns are short. + let img = rings(173, 97); + let all = img.width as usize * img.height as usize; + let opaque: Vec<bool> = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect(); + let own = super::super::own_colours_among(&img, &opaque); + // Two colors for each blend, and an olive that the blends lie nearer to than to their sides, + // each pixel labeled with the nearest of them (none for a transparent one). + let palette = vec![[200, 30, 40], [20, 60, 180], [240, 210, 50], [25, 25, 25], [120, 120, 60]]; + let labels = img + .rgba + .as_chunks::<4>() + .0 + .iter() + .map(|p| { + let d = |c: &[u8; 3]| (0..3).map(|k| (i32::from(p[k]) - i32::from(c[k])).pow(2)).sum::<i32>(); + (0..palette.len()).min_by_key(|&i| d(&palette[i])).map_or(TRANSPARENT, |i| i as u16) + }) + .collect(); + let q = Quantized { labels, palette }; + // One row (or one strip) per share, a few rows per share, and the whole picture as one. + let shares = [1, 3 * 173 + 5, all]; + for (name, settle) in [ + ("settled", &(|s| settled_by(q.clone(), &img, &own, s)) as &dyn Fn(usize) -> Quantized), + ("settled_wide", &|s| settled_wide_by(q.clone(), &img, &own, false, s)), + ("settled_wide gray", &|s| settled_wide_by(q.clone(), &img, &own, true, s)), + ] { + let out: Vec<Quantized> = shares.iter().map(|&s| settle(s)).collect(); + assert_ne!(out[0], q, "{name} settles some blend pixels"); + assert!(out.iter().all(|o| *o == out[0]), "{name}"); + } + } + #[test] fn a_gray_blend_takes_the_closer_of_two_pairs_it_lies_between_despite_rounding() { // Gray 105 in the middle, with own pixels of gray 50 to its left, 200 to its right and black From c3341be03cdb5400051e0badb46c615c7340ecf6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:27:28 -0800 Subject: [PATCH 75/89] Effects: the member limit's doc comment covers clipping paths walked twice A group or layer in a clipping path that has geometry effects of its own is walked once for its members' effects and once for its own, and its members count toward the member limit each time. The doc comment of members_as_drawn now states this. --- crates/effects/src/group.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 34cc98e3bb..1b64971a3a 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -905,7 +905,9 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> { /// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members /// draw. A clip group's clipping path only takes the geometry effects in it into its geometry /// ([`crate::clip::with_clip_effects_applied_while`]), and the members of a clipping group or -/// compound path count toward the limit below. Images, symbol instances and the other objects +/// compound path count toward the limit below. A group or layer in a clipping path that has +/// geometry effects of its own is walked twice, once for its members' effects and once for its +/// own, and its members count each time. Images, symbol instances and the other objects /// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D /// effect with everything in it. Calls nested in one another, through the /// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members From 415783e9263cf10256a82f9c01e71724a0e01c68 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 14 Nov 2026 20:59:15 +0530 Subject: [PATCH 76/89] Symbols: add right-click context menu to panel items --- crates/ui-egui/src/panels/symbols.rs | 239 +++++++++++++++++++++++++++ docs/gaps.md | 2 + docs/target-app-parity.md | 2 + 3 files changed, 243 insertions(+) diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index ad081ddfcd..c0abeb9dac 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -154,6 +154,69 @@ fn has_instances_selected(app: &VectorcraftApp) -> bool { instance_selected(app, |_| true) } +/// Collect the IDs of selected instances of symbol `name` (including inside groups). +fn selected_symbol_instances(app: &VectorcraftApp, name: &str) -> Vec<u64> { + let mut ids = vec![]; + if let Some(st) = app.session.active() { + for id in &st.selection.objects { + if let Some(n) = st.doc.node(*id) { + n.walk(&mut |c| { + if matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == name) { + ids.push(c.id.0); + } + }); + } + } + } + ids +} + +/// A symbol item's context menu. +fn item_context_menu(app: &mut VectorcraftApp, ui: &mut Ui, name: &str) { + let center = app + .view() + .map(|v| v.center) + .or_else(|| app.session.active().and_then(|st| st.doc.artboards.first().map(|a| a.rect.center()))) + .unwrap_or(vectorcraft_geom::Point::ZERO); + + if menu_item(ui, tl!("Place Symbol Instance"), true, false) { + if let Err(e) = app.run("symbol.place", json!({ "name": name, "x": center.x, "y": center.y })) { + app.status(e); + } + ui.close(); + } + if menu_item(ui, tl!("Edit Symbol"), true, false) { + if let Err(e) = app.run("symbol.edit", json!({ "name": name })) { + app.status(e); + } + ui.close(); + } + if menu_item(ui, tl!("Duplicate Symbol"), true, false) { + if let Err(e) = app.run("symbol.duplicate", json!({ "name": name })) { + app.status(e); + } + ui.close(); + } + if menu_item(ui, tl!("Delete Symbol"), true, false) { + delete(app, &[], Some(name), false); + ui.close(); + } + let break_ids = selected_symbol_instances(app, name); + let break_enabled = !break_ids.is_empty(); + if menu_item(ui, tl!("Break Link to Symbol"), break_enabled, false) { + if let Err(e) = app.run("symbol.breakLink", json!({ "ids": break_ids })) { + app.status(e); + } + ui.close(); + } + if menu_item(ui, tl!("Select All Instances"), true, false) { + if let Err(e) = app.run("symbol.selectInstances", json!({ "name": name })) { + app.status(e); + } + ui.close(); + } +} + /// Does the selection hold a symbol instance with appearance changes of its own (a dynamic /// symbol's), which Reset drops? pub(crate) fn changed_instances_selected(app: &VectorcraftApp) -> bool { @@ -249,11 +312,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } chip(ui, &doc, &keys, egui::Rect::from_center_size(r.left_center() + vec2(3.0 + side / 2.0, 0.0), vec2(side, side)), n); ui.painter().text(r.left_center() + vec2(side + 10.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(text), t.text); + let resp = resp.on_hover_text(n); if resp.double_clicked() { clicked = Some((n.clone(), true)); } else if resp.clicked() { clicked = Some((n.clone(), false)); } + if resp.secondary_clicked() && (sel.as_deref() != Some(n) || !marked.is_empty()) { + set_pstate(ui.ctx(), MARKED, (uid, Vec::<String>::new())); + if let Err(e) = app.run("symbol.setCurrent", json!({ "name": n })) { + app.status(e); + } + } + resp.context_menu(|ui| item_context_menu(app, ui, n)); } } else { ui.horizontal_wrapped(|ui| { @@ -274,6 +345,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else if resp.clicked() { clicked = Some((n.clone(), false)); } + if resp.secondary_clicked() && (sel.as_deref() != Some(n) || !marked.is_empty()) { + set_pstate(ui.ctx(), MARKED, (uid, Vec::<String>::new())); + if let Err(e) = app.run("symbol.setCurrent", json!({ "name": n })) { + app.status(e); + } + } + resp.context_menu(|ui| item_context_menu(app, ui, n)); } }); } @@ -1498,4 +1576,165 @@ mod tests { bar(&mut app, vec![egui::Event::PointerMoved(at), button(at, true), button(at, false)]); assert!(close(xf(&app), master), "the Control bar's Reset resets the instance: {:?} vs {master:?}", xf(&app)); } + + /// Right-clicking a symbol in the Symbols panel selects it (if not already selected), + /// displays the context menu, and clicking an item dispatches the corresponding command. + #[test] + fn right_click_context_menu_in_symbols_panel_runs_actions() { + use egui_kittest::kittest::{NodeT, Queryable}; + + let has_instance = |doc: &Document, name: &str| { + let mut found = false; + for l in &doc.layers { + l.walk(&mut |c| found |= matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == name)); + } + found + }; + + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + // Create two symbols "A" and "B" (clearing the instances symbol.new leaves) + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let id_a = app.run("symbol.new", json!({"name": "A"})).unwrap()["id"].as_u64().unwrap(); + app.run("edit.clear", json!({"ids": [id_a]})).unwrap(); + app.run("shape.rectangle", json!({"x": 50, "y": 10, "width": 30, "height": 20})).unwrap(); + let id_b = app.run("symbol.new", json!({"name": "B"})).unwrap()["id"].as_u64().unwrap(); + app.run("edit.clear", json!({"ids": [id_b]})).unwrap(); + + // Currently "B" is the current symbol + assert_eq!(current(&mut app), Some("B".to_string())); + + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 400.0)).with_accessibility_check(false).build_ui_state( + |ui, app: &mut VectorcraftApp| { + show(app, ui); + }, + app, + ); + h.run_steps(2); + + // In thumbnail view, get symbol A + let node_a = h.get_by_label("A"); + let pos_a = node_a.rect().center(); + + // Right-click symbol A + h.event(egui::Event::PointerMoved(pos_a)); + h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + // Right-clicking unselected "A" selected it + assert_eq!(current(h.state_mut()), Some("A".to_string())); + + // Context menu items are displayed + let place = h.get_by_label(" Place Symbol Instance"); + let _ = h.get_by_label(" Edit Symbol"); + let _ = h.get_by_label(" Duplicate Symbol"); + let _ = h.get_by_label(" Delete Symbol"); + let break_link = h.get_by_label(" Break Link to Symbol"); + let _ = h.get_by_label(" Select All Instances"); + + // Break Link to Symbol is disabled because no instances of A are selected + assert!(break_link.accesskit_node().is_disabled()); + + // 1. Click "Place Symbol Instance" + place.click(); + h.run_steps(2); + + // An instance of A is placed on the canvas and selected + let doc = &h.state().session.active().unwrap().doc; + assert!(has_instance(doc, "A")); + + // 2. Right-click symbol A again now that its instance is selected + // Place an instance of B as well and select both to verify Break Link is scoped to A + let inst_b = h.state_mut().run("symbol.place", json!({ "name": "B" })).unwrap()["id"].as_u64().unwrap(); + let inst_a = h.state().session.active().unwrap().doc.layers[0].children().unwrap()[0].id.0; + h.state_mut().run("select.set", json!({ "ids": [inst_a, inst_b] })).unwrap(); + h.run_steps(2); + + h.event(egui::Event::PointerMoved(pos_a)); + h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + // Now "Break Link to Symbol" is enabled + let break_link = h.get_by_label(" Break Link to Symbol"); + assert!(!break_link.accesskit_node().is_disabled()); + break_link.click(); + h.run_steps(2); + + // Link broken for A (no longer a SymbolInstance of A), while B remains an instance + let doc = &h.state().session.active().unwrap().doc; + assert!(!has_instance(doc, "A")); + assert!(has_instance(doc, "B")); + + // 3. Right-click A and click "Duplicate Symbol" + let before_syms = symbols(h.state_mut()); + h.event(egui::Event::PointerMoved(pos_a)); + h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + h.get_by_label(" Duplicate Symbol").click(); + h.run_steps(2); + + let after_syms = symbols(h.state_mut()); + assert_eq!(after_syms.len(), before_syms.len() + 1); + assert!(before_syms.iter().all(|s| after_syms.contains(s))); + assert!(after_syms.contains(&"A copy".to_string())); + + // 4. Right-click B and click "Edit Symbol" + let node_b = h.get_by_label("B"); + let pos_b = node_b.rect().center(); + h.event(egui::Event::PointerMoved(pos_b)); + h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + assert_eq!(current(h.state_mut()), Some("B".to_string())); + h.get_by_label(" Edit Symbol").click(); + h.run_steps(2); + assert!(h.state().session.active().unwrap().doc.symbol_edit.is_some()); + + // Exit symbol editing mode + h.state_mut().run("symbol.exitEditing", json!({})).unwrap(); + h.run_steps(2); + + // 5. Right-click B and click "Delete Symbol" + h.event(egui::Event::PointerMoved(pos_b)); + h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + h.get_by_label(" Delete Symbol").click(); + h.run_steps(2); + + // Dialog opened; cancelling preserves B + assert!(h.state().ui.dialog.is_some()); + h.state_mut().ui.dialog = None; + assert!(symbols(h.state_mut()).contains(&"B".to_string())); + + // Right-click B again and confirm deletion + h.event(egui::Event::PointerMoved(pos_b)); + h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: pos_b, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.get_by_label(" Delete Symbol").click(); + h.run_steps(2); + crate::dialogs::confirm(h.state_mut()).unwrap(); + assert!(!symbols(h.state_mut()).contains(&"B".to_string())); + + // Also test list view + set_pstate(&h.ctx, VIEW, View::SmallList); + h.run_steps(2); + let node_a = h.get_by_label("A"); + let pos_a = node_a.rect().center(); + h.event(egui::Event::PointerMoved(pos_a)); + h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: pos_a, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + assert!(h.query_by_label(" Place Symbol Instance").is_some()); + h.get_by_label(" Select All Instances").click(); + h.run_steps(2); + } } diff --git a/docs/gaps.md b/docs/gaps.md index 1e0dc97609..36dd83cd3c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G12: Symbols panel context menu: right-click on a symbol in thumbnail or list view displays a context menu (Place Symbol Instance, Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol, Select All Instances), selecting the symbol first if unselected; scores and estimates unchanged. | | 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); estimate decremented (5–15 → 4–14 h). | | 2026-11-14 | minor | G12: Layers panel row context menu (Options…, Duplicate, Delete, Lock Others/Show All/Hide Others/Unlock All, Locate Object, Collect for Export) with selection handling and undo tracking. Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups `<g>`, or as live text in `TextAs::Text` mode if `/ToUnicode` exists on non-colour fonts (`TextAs::Outlines` produces grouped outlines) (#6740); scores and estimates unchanged. | @@ -465,6 +466,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G12 Preferences and panels that don't act yet +- **Done (2026-11-14):** Symbols panel context menu: right-click on a symbol in thumbnail or list view displays a context menu with Place Symbol Instance, Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol (enabled when canvas selection includes instances of the symbol), and Select All Instances, selecting the symbol if unselected. Estimate unchanged. - **Done (2026-10-29, #4522):** the Pathfinder panel menu's Make, Release and Expand Compound Shape, which were dimmed placeholders, and the panel's Expand button act on live compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html)): Alt-clicking a Shape Mode makes one in that mode, a Shape Mode sets the mode of the members selected, and the compound shape draws, exports and saves as the area its members' modes make (`object.compoundShape.*`, `crates/engine/src/tests/compound_shape.rs`). Left: blends, envelopes and warps as members, question marks on the buttons for mixed member modes, clicks that hit the area rather than the members. Estimate unchanged. - **Done (2026-10-25, #3426):** Pathfinder panel › Pathfinder Options… ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): Precision (0.001–10 pt; results are refit to within it and pieces thinner than it are dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (pieces of objects with no fill), kept in the preferences (`object.pathfinder.options`, group `pathfinder`) and used by every Shape Mode and Pathfinder button and Repeat; the page gives no defaults, so ours (0.01 pt, both off) keep the earlier results byte for byte. Trap is left as it was. Estimate unchanged. - **Done (2026-10-25):** Artboards panel › Delete Empty Artboards ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html), [artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html)): `artboard.deleteEmpty` deletes every unlocked artboard with no artwork, with its guides, in one undo step, and keeps artboard 1 when every artboard is empty and none is locked; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). The Artboard tool's canvas context menu lists it too. Estimate unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..0fa8fb4ab4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -968,6 +968,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it may pass the art's edges (see the next entry) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_passes_the_arts_edges_and_stops_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. - **9-slice guides outside the art** ([the help page](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section): a guide may lie outside the symbol's art, set by dragging it past the art's edge in symbol editing mode, by `symbol.options`'s `nineSliceGuides` or by a Symbol Options field below 0% or above 100%, and a scaled instance still scales, by the cells its art falls in. VectorCraft's own rules: each guide lies within 16 times the art's width or height of its edge (`-16..16` as a fraction, `-1600..1600` in percent) and the two guides of an axis keep their order; a guide past an edge of the art cuts the art at that edge, so the outer cell on its side holds no art; with both guides of an axis past opposite edges the art scales as a whole along it; with both past the same edge the art lies in the outer cell at the other edge and keeps its size, from that edge of the instance, or shrinks to the instance's width or height when the instance is smaller than the art; Symbol Options shows and keeps the guides unrounded. Native files keep the guides exactly; SVG and `.ai` write such an instance as its placed art and keep no guides. Tests: `symbol_nine_slice::tests::guides_outside_the_art_are_accepted_in_order_and_within_the_limit`, `art_scales_by_the_cells_it_falls_in_when_guides_lie_outside_it`, `the_map_with_guides_outside_the_art_is_continuous_and_monotone` in `crates/doc`, `guides_outside_the_art_are_set_by_command_and_scale_by_the_cells_that_hold_art`, `a_guide_dragged_past_the_arts_edge_lies_outside_it`, `guides_outside_the_art_survive_saving_and_reopening_a_native_file` in `crates/engine`, `nineslice::tests::a_guide_outside_the_art_is_taken_and_dragged_past_the_arts_edge`, `a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn` (SVG and `.ai`). - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. + - **Symbols panel context menu** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): right-clicking a symbol in thumbnail view or list view displays a context menu with Place Symbol Instance (passes view center or artboard center), Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol (enabled when canvas selection includes instances of this symbol), and Select All Instances. Right-clicking an unselected symbol selects it first (`symbol.setCurrent`). Tests: `panels::symbols::tests::right_click_context_menu_in_symbols_panel_runs_actions`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. - **Symbols › Sort by Name and the list views** ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Sort by Name puts the symbols in alphabetical order (`symbol.sortByName`), and its Thumbnail View, Small List View and Large List View show them as thumbnails, in a compact list or in a list with larger thumbnails. VectorCraft's own rules: the sort ignores case and keeps names that differ only in case in their order; it changes the document's order, which `symbol.list` reports, as one undo step, keeps the current symbol and adds no undo step when the symbols are already in order; Thumbnail View is the default, Small List View has 18-point rows with 14-point thumbnails and Large List View 26-point rows with 22-point thumbnails, and a click or double-click on a symbol does the same in every view. Tests: `tests::brushsym::symbol_sort_by_name_orders_the_symbols_in_one_undo_step`, `panels::symbols::tests::sort_by_name_and_the_small_and_large_list_views`. - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). @@ -2452,6 +2453,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. | +| 2026-11-14 | minor | Symbols panel context menu: right-click on a symbol in thumbnail or list view displays a context menu (Place Symbol Instance, Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol, Select All Instances), selecting the symbol first if unselected; scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. | | 2026-11-11 | minor | Guides and snapping: a Shift move slides the selection along its line onto a ruler guide by its edges and centre, as a move without Shift does (#4312); a guide a nearly parallel line only reaches a long way off pulls nothing. Score and hours unchanged (estimated). | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | From da8f6ab721ed3bef5c33426d4be08deba776d08c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:31:41 -0800 Subject: [PATCH 77/89] Smooth tool: a whole closed path with a corner starts nearest its old start When a drag reaches every anchor of a closed path that has a corner, the path is cut open at the corner and refitted. The result started at the corner unless the refit kept an anchor exactly at the old first anchor. It now starts at its point nearest the old first anchor, at an anchor there or at a new anchor split into the curve, as the other closed-path cases do. --- crates/engine/src/cmd/draw2/smooth.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index e91fa138e4..be7db9a1ae 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -571,11 +571,9 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f if let Some(r0) = res.first_mut() { *r0 = Anchor::with_handles(r0.p, l.h_in, r0.h_out); } - // The path starts where it did when the refit keeps an anchor there. - if let Some(j) = res.iter().position(|a| a.p == sp.anchors[0].p) { - res.rotate_left(j); - } - return Some(SubPath::new(res, true)); + // The path starts where it did when the refit keeps an anchor there, and otherwise at its + // point nearest there. + return Some(start_nearest(SubPath::new(res, true), start, sp)); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. let mut open = sp.clone(); From 2046c646fae9a283d8d439a8eb90bce1b7a91cd4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:42:53 -0800 Subject: [PATCH 78/89] Image Trace: blend settling caps its pairs of label changes at the size of a label copy The pairs a band or group of strips keeps for its changed labels now grow by doubling only up to the count that fits in a copy of its labels, so they never take more memory than that copy. The next change moves them into the copy. - Tests: most of a share changing gives the same labels as when few change, for both settle passes and every share size (the copy path); both settle passes return a picture with no pixels unchanged. --- crates/trace/src/quantize.rs | 4 +- crates/trace/src/quantize/cut_edge.rs | 61 +++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index e89f3eda3b..9a7031256a 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -374,9 +374,9 @@ mod tests { [c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }] }); let all = w as usize * h as usize; - // One row per share, a few rows per share, and the whole picture as one. let opaque: Vec<bool> = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect(); - let own: Vec<Vec<bool>> = [1, 3 * 173 + 5, all].iter().map(|&s| own_colours_among_by(&img, &opaque, s)).collect(); + // One row per share, a few rows per share, and the whole picture as one. + let own: Vec<Vec<bool>> = [1, 3 * w as usize + 5, all].iter().map(|&s| own_colours_among_by(&img, &opaque, s)).collect(); assert_eq!(own[0].len(), all); assert!(own[0].iter().any(|&o| !o), "the edges have blend pixels"); assert!(own.iter().all(|o| *o == own[0])); diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 2155d6d2af..265c0d9b2c 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -89,7 +89,8 @@ fn settled_by(mut q: Quantized, img: &Raster, own: &[bool], per_share: usize) -> /// The labels a pass changes in one part of a picture, `len` pixels long, counting each pixel from /// the part's first. They are kept as pairs of a pixel and its new label until the pairs would take /// more memory than a copy of the part's labels, and as that copy, with the changes made, after -/// that. A part takes at most about as much memory as a copy of its labels either way. +/// that. The pairs take at most as much memory as the copy, and a part holds both only while it +/// moves the pairs into the copy. struct Changes { len: usize, few: Vec<(usize, u16)>, @@ -110,16 +111,23 @@ impl Changes { } return; } - self.few.push((at, to)); - if self.few.len() * size_of::<(usize, u16)>() > self.len * size_of::<u16>() { - let mut copy = labels(); - for (at, to) in std::mem::take(&mut self.few) { - if let Some(l) = copy.get_mut(at) { - *l = to; - } + // The most pairs that take no more memory than a copy of the part's labels. The pairs grow + // by doubling up to that many, and no further. + let most = self.len * size_of::<u16>() / size_of::<(usize, u16)>(); + if self.few.len() < most { + if self.few.len() == self.few.capacity() { + self.few.reserve_exact(self.few.capacity().max(4).min(most - self.few.len())); } - self.copy = Some(copy); + self.few.push((at, to)); + return; } + let mut copy = labels(); + for (at, to) in std::mem::take(&mut self.few).into_iter().chain([(at, to)]) { + if let Some(l) = copy.get_mut(at) { + *l = to; + } + } + self.copy = Some(copy); } fn is_empty(&self) -> bool { @@ -789,6 +797,7 @@ mod tests { changes.set(at * 3, 99, || labels.clone()); } assert!(changes.copy.is_none()); + assert!(changes.few.capacity() <= pairs, "the pairs take no more memory than a copy of the labels"); changes.set(63, 98, || labels.clone()); assert!(changes.few.is_empty() && changes.copy.is_some()); changes.set(1, 97, || labels.clone()); @@ -834,6 +843,40 @@ mod tests { } } + #[test] + fn blend_pixels_settle_the_same_when_most_of_a_share_changes() { + // Columns of red and blue own pixels, two blend pixels between each pair: one nearer to red + // and one nearer to blue, both labeled with an olive. Half the pixels of each band of rows and + // of each group of strips change, more than the eighth at which a share's changes are copied + // out (see [`Changes`]). 44 columns, so that the last strip of 7 is short. + let (w, h) = (44u32, 5u32); + let (red, blue) = ([200u8, 30, 40], [20u8, 60, 180]); + let mix = |t: f64| [0, 1, 2].map(|k| (f64::from(red[k]) + t * (f64::from(blue[k]) - f64::from(red[k]))).round() as u8); + let img = Raster::from_fn(w, h, |x, _| { + let c = [red, mix(0.3), mix(0.7), blue][x as usize % 4]; + [c[0], c[1], c[2], 255] + }); + let own: Vec<bool> = (0..w * h).map(|i| matches!(i % w % 4, 0 | 3)).collect(); + let labels = (0..w * h).map(|i| [0, 2, 2, 1][(i % w % 4) as usize]).collect(); + let q = Quantized { labels, palette: vec![red, blue, [120, 120, 60]] }; + let expected = Quantized { labels: (0..w * h).map(|i| [0, 0, 1, 1][(i % w % 4) as usize]).collect(), palette: vec![red, blue] }; + let all = (w * h) as usize; + for s in [1, 3 * 7 * h as usize, all] { + assert_eq!(settled_by(q.clone(), &img, &own, s), expected, "settled, {s} pixels per share"); + assert_eq!(settled_wide_by(q.clone(), &img, &own, false, s), expected, "settled_wide, {s} pixels per share"); + } + } + + #[test] + fn a_picture_with_no_pixels_settles_to_itself() { + let q = Quantized { labels: vec![], palette: vec![[0, 0, 0], [255, 255, 255]] }; + for (w, h) in [(u32::MAX, 0), (0, u32::MAX)] { + let img = Raster::new(w, h, vec![]).unwrap(); + assert_eq!(settled_by(q.clone(), &img, &[], PIXELS_PER_SHARE), q, "settled, {w} x {h}"); + assert_eq!(settled_wide_by(q.clone(), &img, &[], false, PIXELS_PER_SHARE), q, "settled_wide, {w} x {h}"); + } + } + #[test] fn a_gray_blend_takes_the_closer_of_two_pairs_it_lies_between_despite_rounding() { // Gray 105 in the middle, with own pixels of gray 50 to its left, 200 to its right and black From dac3fe54d85eebff565d08f9923d86b30a60ce75 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:49:48 -0800 Subject: [PATCH 79/89] Learn: Gujarati rows for the new command labels The Gujarati catalog lists every menu and command label, and it had no rows for Show Learn Tab, Show Skill and Back One Level. It now has them. docs/mcp.md said the For you and Skills tab buttons run learn.tab. A tab button runs it only when the panel shows the other tab, and the page now says so. --- crates/ui-egui/src/i18n/gu.tsv | 3 +++ docs/mcp.md | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..bf7e0a4c91 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -4533,6 +4533,9 @@ antiAlias Sharp શાર્પ Open Tutorial Sample ટ્યુટોરિયલનો નમૂનો ખોલો Finish Tutorial ટ્યુટોરિયલ પૂર્ણ કરો Next Tutorial આગળનો ટ્યુટોરિયલ + Show Learn Tab શીખો ટૅબ બતાવો + Show Skill કૌશલ્ય બતાવો + Back One Level એક સ્તર પાછા જાઓ Back to Skills કૌશલ્યો પર પાછા જાઓ Cutting Tools કટિંગ ટૂલ્સ Stencil Bridges… સ્ટેન્સિલ બ્રિજિસ… diff --git a/docs/mcp.md b/docs/mcp.md index ce1d6e1da6..d9c72ab251 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2925,9 +2925,10 @@ zero-based indexes of its steps marked done (every step of a finished tutorial). skills, with no skill and no tutorial open, as the Quick skill guide card and Back to skills do, and answers what `learn.list` answers. `learn.show {tutorial}` shows the panel on its Skills tab with that tutorial's steps → `{skill, tutorial, title, steps: [text]}`; choosing a tutorial's row runs it. A missing, non-string -or unknown `tutorial` is an error and changes nothing. The For you and Skills tab buttons run `learn.tab {tab}`, with -`tab` set to `forYou` or `skills`, which shows the panel on that tab → `{tab, skill, tutorial}`, with null for no -skill or no tutorial open; the Skills tab keeps the skill and tutorial it had open. Choosing a skill's row runs +or unknown `tutorial` is an error and changes nothing. A click on the For you or Skills tab button runs +`learn.tab {tab}` when the panel shows the other tab, and runs no command when the panel already shows that tab. +`learn.tab {tab}`, with `tab` set to `forYou` or `skills`, shows the panel on that tab → `{tab, skill, tutorial}`, +with null for no skill or no tutorial open; the Skills tab keeps the skill and tutorial it had open. Choosing a skill's row runs `learn.skill {skill}`, which shows the panel on the Skills tab with that skill's tutorials → the skill as `learn.list` lists it. A missing, non-string or unknown `tab` or `skill` is an error and changes nothing. Back runs `learn.back {}`, which shows the panel and goes up one level on the Skills tab, from a tutorial's steps to its skill's tutorials and From 10225d82e587ee7bcf2f86c8ee9d855340198ec3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:50:21 -0800 Subject: [PATCH 80/89] Export: an HP-GL plot of the art's bounds leaves out images in symbol art The bounds of a plot of the art's bounds no longer count the objects a plot leaves out (placed images, objects at 0% opacity, unpainted paths) when they sit in the art of a symbol. An instance whose symbol holds nothing else is left out, so a document with only such instances is refused with "nothing to export". The art of a dynamic symbol, and of a group with effects, fills or strokes of its own, is measured whole. --- crates/engine/src/cmd/fileio/encode.rs | 93 ++++++++++++++++++++-- crates/engine/src/cmd/fileio/tests_hpgl.rs | 33 ++++++++ docs/mcp.md | 2 + 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index cc8fc60f8f..3461aca056 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -2,6 +2,7 @@ //! (unknown keys are ignored, so one params object can carry the options of several formats). use std::borrow::Cow; +use std::collections::HashSet; use std::sync::Arc; use serde::Deserialize; @@ -447,14 +448,94 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> { } /// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden -/// ([`hidden_nodes`]). +/// ([`hidden_nodes`]) and the ones in the art of the symbols that instances show taken out +/// ([`without_nodes`]: symbol art is measured with its hidden objects). An instance of a symbol +/// whose art is all picked or hidden is hidden too. The art of a dynamic symbol is measured whole: +/// an instance can paint its objects, and finds them by their places in the art. fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> { - let drawn = hidden_nodes(&doc.layers, left_out).map(|layers| { - let mut d = doc.clone(); + // The symbols instances show (the first of each name) that aren't dynamic. + let mut names = HashSet::new(); + let shown: Vec<(usize, &vectorcraft_doc::Symbol)> = + doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).filter(|(_, s)| !s.dynamic).collect(); + // The symbols whose art `left_out` leaves nothing of, those that only hold instances of such + // symbols included. + let mut empty: HashSet<&str> = HashSet::new(); + while let Some((_, s)) = shown.iter().find(|(_, s)| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty)) { + empty.insert(&s.name); + } + let left_out = + &|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n)); + let layers = hidden_nodes(&doc.layers, left_out); + let symbols: Vec<(usize, Arc<Node>)> = shown + .iter() + .filter(|(_, s)| !empty.contains(s.name.as_str())) + .filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art))) + .collect(); + if layers.is_none() && symbols.is_empty() { + return vectorcraft_render::encode::art_bounds(doc); + } + let mut d = doc.clone(); + if let Some(layers) = layers { d.layers = layers; - d - }); - vectorcraft_render::encode::art_bounds(drawn.as_ref().unwrap_or(doc)) + } + for (i, art) in symbols { + if let Some(s) = d.symbols.get_mut(i) { + s.art = art; + } + } + vectorcraft_render::encode::art_bounds(&d) +} + +/// Whether `n` is hidden, picked by `left_out`, an instance of one of the `empty` symbols, or a +/// layer or a group holding only such objects (its clipping path among them when it clips). A +/// Shaper group, or a layer, group or instance with effects, fills or strokes of its own, is none +/// of these unless it is hidden or picked. +fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>) -> bool { + !n.visible + || left_out(n) + || !has_effects(n) + && n.extras().shaper.is_none() + && match &n.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().all(|c| all_left_out(c, left_out, empty)), + NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()), + _ => false, + } +} + +/// Whether `n` has a visible effect, fill or stroke of its own, which the objects it holds can +/// take part in. +fn has_effects(n: &Node) -> bool { + n.appearance.effects.iter().any(|e| e.visible) || n.appearance.items.iter().any(|i| i.visible()) +} + +/// `n` without the objects that `left_out` picks in it, when it is a layer or a group, and in the +/// layers and groups in it at any depth; `None` when it picks none of them. A clipping layer or +/// group keeps its clipping path, its first object, and a Shaper group and a layer or group with +/// effects, fills or strokes of its own keep all they hold. +fn without_nodes(n: &Arc<Node>, left_out: &dyn Fn(&Node) -> bool) -> Option<Arc<Node>> { + let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None }; + if has_effects(n) || n.extras().shaper.is_some() { + return None; + } + let mut changed = false; + let kept: Vec<Arc<Node>> = children + .iter() + .enumerate() + .filter_map(|(i, c)| { + if left_out(c) && !(*clip && i == 0) { + changed = true; + return None; + } + Some(without_nodes(c, left_out).inspect(|_| changed = true).unwrap_or_else(|| c.clone())) + }) + .collect(); + changed.then(|| { + let mut new = (**n).clone(); + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind { + *children = kept; + } + Arc::new(new) + }) } /// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 09fe5e132c..5b7e3f0185 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -221,6 +221,39 @@ fn a_plot_of_the_arts_bounds_leaves_out_the_layers_it_does_not_plot() { assert!(e.contains("no visible art"), "{e}"); } +/// A placed image in a symbol's art doesn't move a plot of the art's bounds either, and a +/// document whose only other visible art is an instance of that symbol is refused. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() { + let mut s = two_boards(); + let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v)); + let alone = plot(&mut s).unwrap(); + let printing = s.doc().unwrap().doc.layers[0].id; + s.execute("layer.new", &json!({})).unwrap(); + let mut png = vec![]; + image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Far"})).unwrap(); + assert!(s.doc().unwrap().doc.symbols.iter().any(|sym| sym.name == "Far")); + assert_eq!(plot(&mut s).unwrap(), alone, "an image in symbol art"); + + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + + // A symbol of a square and an image is measured by its square. + let bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap(); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone(); + let before = bounds(&s); + let image = s.execute("file.place", &json!({"name": "near.png", "dataBase64": b64(&png), "at": [400, 600]})).unwrap()["ids"][0].clone(); + s.execute("symbol.new", &json!({"name": "Both", "ids": [square, image]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.symbols.len(), 2); + let after = bounds(&s); + for (a, b) in [(before.x0, after.x0), (before.y0, after.y0), (before.x1, after.x1), (before.y1, after.y1)] { + assert!((a - b).abs() < 1e-6, "{before:?} {after:?}"); + } +} + #[test] fn export_options_are_checked() { let mut s = two_boards(); diff --git a/docs/mcp.md b/docs/mcp.md index b71e8ab752..5f9abe7988 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1089,6 +1089,8 @@ way. `document.exportPdf {useArtboards: false}` writes that one page as well, an (unless `includeNonPrinting` or `createLayers` keeps them; a `.ai` file keeps layers by default). An HP-GL plot of the art's bounds is measured without what the plot leaves out: non-printing layers, objects at 0% opacity, placed images, gradient meshes whose every point is at 0% opacity, and paths without effects whose fills and strokes paint nothing. +Such objects in the art of a symbol that is not dynamic are left out of its instances too, and an instance whose +symbol holds nothing else is left out. A document whose only visible art is left out is refused, and Export As refuses an HP-GL plot of it before the save dialog. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with From 40d0b31cbe4de180f99d61c5abc82371aca4c13d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:54:44 -0800 Subject: [PATCH 81/89] Effects: the clipping path doc comment says what the member limit leaves of a compound path When the member limit is reached among the paths of a compound clipping path that has geometry effects of its own, the compound path still becomes one path of its reshaped geometry. The paths that were not visited go into that geometry as they are, and their own effects are not applied. The doc comment of with_clip_effects_applied_while said that they keep their effects, which holds for the members of a group or layer only. New test a_compound_clipping_path_takes_its_own_effect_at_the_member_limit covers the compound path. --- crates/effects/src/clip.rs | 5 ++++- crates/effects/src/group/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index bcb2dd0995..d04a60b70a 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -36,7 +36,10 @@ pub(crate) fn with_clip_effects_applied(n: &Node) -> Option<Node> { /// [`with_clip_effects_applied`] that calls `take` before it visits each member of a group, layer /// or compound path in `n`, at every depth: once `take` returns `false`, that member and the -/// members after it stay as they are, with their effects. +/// members after it are not visited. The members of a group or layer that are not visited stay as +/// they are, with their effects. A compound path with geometry effects of its own, or of its fills +/// and strokes, still becomes a path of its reshaped geometry: the paths in it that are not visited +/// go into that geometry as they are, and their own effects are not applied. pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option<Node> { let paint = n.clip_paint(); clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 8b8fda5727..519323e5f0 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -871,6 +871,31 @@ fn clipping_path_members_past_the_member_limit_stay_as_they_are() { assert!((widths[0] - 120.0).abs() < 0.5 && (widths[1] - 120.0).abs() < 0.5 && (widths[2] - 100.0).abs() < 0.5, "{widths:?}"); } +/// A compound clipping path with an effect of its own takes that effect when the member limit is +/// reached among its paths: with room for one more member, the first path (at 100 to 200) takes +/// its Offset Path, the second (at 300 to 400) goes in as it is, and the compound path's Offset +/// Path reshapes both, to 80 to 220 and 290 to 410. +#[test] +fn a_compound_clipping_path_takes_its_own_effect_at_the_member_limit() { + let member = |id: u64, x: f64| { + let path = Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), black()); + Arc::new(with_effect(path, offset_10())) + }; + let kind = NodeKind::Compound { children: vec![member(2, 100.0), member(3, 300.0)], rule: FillRule::NonZero }; + let clipping = with_effect(Node::new(NodeId(20), kind), offset_10()); + let mut clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clipping), Arc::new(square_at_100())], clip: true }); + // An enclosing call that has visited all the members it may but one. + let _outer = DrawnFrame::enter().unwrap(); + DRAWN.with(|d| d.set((1, crate::SYMBOL_ART_MAX_NODES - 1))); + members_as_drawn(None, &mut clipped); + let clipping = clipped.children().unwrap()[0].clone(); + assert!(crate::reshape::geometry_effects(&clipping).is_empty(), "the compound path's Offset Path is in its geometry"); + let subpaths = clipping.path_data().unwrap().subpaths.clone(); + let bounds: Vec<(f64, f64)> = subpaths.into_iter().map(|sp| PathData::single(sp).bounds().unwrap()).map(|b| (b.x0, b.x1)).collect(); + let near = |(a, b): (f64, f64), (x0, x1): (f64, f64)| (a - x0).abs() < 0.5 && (b - x1).abs() < 0.5; + assert!(bounds.len() == 2 && near(bounds[0], (80.0, 220.0)) && near(bounds[1], (290.0, 410.0)), "{bounds:?}"); +} + /// An image or a symbol instance that a group's Transform effect moves takes the placed gradients /// of its own fills along, as a path does, and each copy takes them as many times as it takes the /// transform. From ae39914b9fb6b37bbc3257a27b2bdb89c361632a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 08:58:57 -0800 Subject: [PATCH 82/89] Remove Breaks: a partly selected story's frames are gone through once When only some frames of a threaded story are selected, Remove Line Breaks and Remove Paragraph Breaks built the story's frame list again for every selected frame of it, so the work grew with the square of the frame count. The command now notes a thread once its story is found not to be selected in full, and reads that thread's other selected frames on their own without going through the thread again. Each selected frame still loses its own breaks, in the order selected, as one undo step. A test-only counter in threads.rs counts story_in calls, and remove_breaks_goes_through_a_partly_named_story_once checks both break kinds with frames named out of order and twice. --- crates/engine/src/cmd/addons/cleanup/text.rs | 10 ++++-- .../src/cmd/addons/cleanup/text/tests.rs | 32 +++++++++++++++++++ crates/engine/src/cmd/threads.rs | 10 ++++++ docs/target-app-parity.md | 1 + 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 00d75e9888..a13110cf67 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -871,13 +871,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> { let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect(); let threads = thread_index(d); let mut done = std::collections::HashSet::new(); + // The threads already found not to be a story whose frames are all among them: their + // other frames are read one at a time without going through the thread again. + let mut partial = std::collections::HashSet::new(); let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![]; for &id in &ids { if !done.insert(id) { continue; } - let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) { - Some(thread) => crate::cmd::threads::story_in(d, thread, id), + let thread = threads.get(&id).copied(); + let story = match thread.filter(|i| !partial.contains(i)).and_then(|i| d.text_threads.get(i)) { + Some(frames) => crate::cmd::threads::story_in(d, frames, id), None => vec![], }; if !story.is_empty() && story.iter().all(|f| named.contains(f)) { @@ -885,6 +889,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> { // Every frame is among them, so each one keeps its breaks. out.extend(story_cuts(d, &story, brk, true)); } else if let Some(t) = text_of(d, id) { + // `id` is type, so the story of its thread is the same for each of its frames. + partial.extend(thread); out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk))); } } diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index c9ed79122c..9671bb3306 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -379,6 +379,38 @@ fn remove_breaks_reads_the_threads_once() { } } +/// Remove Breaks reads each named frame of a story that is not all named on its own, in the order +/// named and each once, as one undo step, and goes through that story's frames once for all of them. +#[test] +fn remove_breaks_goes_through_a_partly_named_story_once() { + for (kind, brk) in [("line", '\u{2028}'), ("paragraph", '\n')] { + let mut s = session(); + let a = text(&mut s, json!({"x": 10, "y": 20, "text": "a\u{2028}b\n".repeat(40), "size": 12, "area": {"width": 150, "height": 60}})); + let mut roots = vec![a]; + for i in 0..4 { + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 100 + 70 * i, "width": 150, "height": 60})).unwrap(); + roots.push(NodeId(r["id"].as_u64().unwrap())); + } + select(&mut s, &roots); + s.execute("text.thread.create", &json!({})).unwrap(); + let f = s.doc().unwrap().doc.text_threads[0].clone(); + assert_eq!(f.len(), 5); + let named = [f[4], f[1], f[3]]; + let breaks: Vec<usize> = named.iter().map(|id| obj(&s, *id).plain_text().matches(brk).count()).collect(); + assert!(breaks.iter().all(|n| *n > 0), "{kind}: each named frame holds breaks: {breaks:?}"); + let before = doc_json(&s); + let steps = undo_steps(&s); + let builds = crate::cmd::threads::story_builds(); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": kind, "ids": [f[4].0, f[1].0, f[4].0, f[3].0]})).unwrap(); + assert_eq!(crate::cmd::threads::story_builds() - builds, 1, "{kind}: the story's frames are gone through once"); + assert_eq!(r["removed"].as_u64(), Some(breaks.iter().sum::<usize>() as u64), "{kind}"); + assert_eq!(ids(&r), named, "{kind}"); + assert_eq!(undo_steps(&s), steps + 1, "{kind}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before, "{kind}"); + } +} + /// Lock `id` (`locked`), else hide it, as an undo step of its own. fn lock_or_hide(s: &mut Session, id: NodeId, locked: bool) { s.edit("Lock or Hide", |d, _| { diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 35f67f64c2..ad41d1d220 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -400,6 +400,8 @@ thread_local! { /// Tests: how many times [`story_of`] and [`ThreadIndex::of`] went through the document's /// threads on this thread. static THREAD_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; + /// Tests: how many times [`story_in`] went through a thread's frames on this thread. + static STORY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; } /// Tests: how many times [`story_of`] and [`ThreadIndex::of`] have gone through the document's @@ -409,6 +411,12 @@ pub(crate) fn thread_scans() -> usize { THREAD_SCANS.with(std::cell::Cell::get) } +/// Tests: how many times [`story_in`] has gone through a thread's frames on this thread. +#[cfg(test)] +pub(crate) fn story_builds() -> usize { + STORY_BUILDS.with(std::cell::Cell::get) +} + /// The frames of the thread that `id` is a frame of, in thread order, each area type or type on a /// path; empty when `id` is in no thread or the thread has fewer than two such frames. pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> { @@ -420,6 +428,8 @@ pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> { /// [`story_of`] for `id` in `thread`, the first of the document's threads that lists it. pub(crate) fn story_in(d: &Document, thread: &[NodeId], id: NodeId) -> Vec<NodeId> { + #[cfg(test)] + STORY_BUILDS.with(|c| c.set(c.get() + 1)); let frames: Vec<NodeId> = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect(); if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..1f3c1e492a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1022,6 +1022,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). + - When only some frames of a threaded story are selected, Remove Line Breaks and Remove Paragraph Breaks go through that story's frames once for all of them, not once per selected frame; each selected frame still loses its own breaks, in the order selected, as one undo step (`addons::cleanup::text::tests::remove_breaks_goes_through_a_partly_named_story_once`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`). - With the Selection tool, a Character or Paragraph panel change to a selected frame of threaded text changes every frame of its story, locked and hidden frames included, as one undo step. The anti-aliasing method, Variable Font axes, the Tabs panel's tab stops and both panels' Reset Panel change the whole story too, and the panels show a value only when every frame of the story has it. A story selected through several of its frames or through a group is changed once, and other type keeps its formatting (VectorCraft's own behavior; `panels::character::tests::selection_tool_changes_reach_every_frame_of_the_story`, `selection_tool_fields_read_every_frame_of_the_story`, `selected_stories_list_each_frame_once`, `panels::paragraph::tests::selection_tool_paragraph_changes_reach_every_frame_of_the_story`, `panels::tabs::tests::selection_tool_tab_stops_reach_every_frame_of_the_story`). From f43d16268a9e2b4d4e1051ead7010060c2d20a37 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 09:43:51 -0800 Subject: [PATCH 83/89] Global Edit: rotated copies are matched and take each edit turned by their angle Select > Start Global Edit now also takes rotated copies of the selected object or group, in the highlights before the start, in the Global Edit Options' Results count and in the started edit. An edit reaches each copy turned by that copy's angle, so an anchor moved to the right on the selected object moves downward on a copy turned a quarter turn clockwise. The copy's angle is the one that lays the selected object's anchors and handles on the copy's in order (a least-squares fit in the unit box), and the copy is the same shape when each point then lies within a millionth of the larger side of the copy's box. Reflected copies and copies stretched along one direction stay unmatched. With Size, a rotated copy matches when it is the object rotated without scaling. The no-match message now names only reflected copies, in every catalog that has it. Docs: docs/mcp.md, docs/gaps.md, docs/ui-parity.md and the evidence list in docs/target-app-parity.md. --- crates/engine/src/cmd/globaledit.rs | 160 +++++++++++++++++++----- crates/engine/src/tests/globaledit.rs | 172 ++++++++++++++++++++++++-- crates/ui-egui/src/i18n/bs.tsv | 2 +- crates/ui-egui/src/i18n/ca.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hi.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ka.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/nl.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- docs/gaps.md | 6 +- docs/mcp.md | 20 ++- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 30 files changed, 341 insertions(+), 68 deletions(-) diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index f454d7133b..5cbdd8f291 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -6,20 +6,34 @@ //! (<https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html>), in our words: //! //! - With one object selected, Start Global Edit finds the objects that repeat its shape: the -//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance -//! (the same fill and stroke too) and Size (the same width and height too). The search covers -//! every artboard and the canvas around them by default; it can be limited to a range of -//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type, -//! masks, linked files and selections of more than one object are not supported. +//! same outline, wherever it lies, at whatever size and at whatever angle. Two options narrow +//! the match: Appearance (the same fill and stroke too) and Size (the same width and height +//! too). The search covers every artboard and the canvas around them by default; it can be +//! limited to a range of artboards (`1, 3-5`) and can leave out the objects that lie on no +//! artboard. Images, type, masks, linked files and selections of more than one object are not +//! supported. //! - The help page "Edit groups with similar objects" //! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html>): //! with a group selected, Start Global Edit finds the groups like it, and an edit made to an //! object inside one of them is made to the matching object in each of the others. Here a group //! is like another when it holds as many objects, in the same order, each like the one at its //! place (paths, compound paths, symbol instances and groups of them, clipping paths included), -//! and they lie the same way relative to each other: the group as a whole is moved or scaled. -//! With Appearance on, each of those objects must have the same fill and stroke too; with Size, -//! the groups the same width and height. +//! and they lie the same way relative to each other: the group as a whole is moved, scaled or +//! rotated. With Appearance on, each of those objects must have the same fill and stroke too; +//! with Size, the groups the same width and height. +//! - The how-to "Design a modern geometric logo" +//! (<https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html>) makes rotated +//! copies of a group of shapes around a point, then starts Global Edit on one object and edits +//! every copy of it at once. Here a rotated copy is found as a moved or scaled one is, and an +//! edit reaches it turned by its own angle: an anchor moved to the right on the selected object +//! moves downward on a copy turned a quarter turn clockwise. The rules below for rotated copies +//! are VectorCraft's own. The angle is the one that lays each anchor and handle of the selected +//! object on the copy's of the same order, so a square whose anchors were turned a quarter turn +//! is a copy turned a quarter turn. A copy is the same shape when, laid so, every anchor and +//! handle lies within a millionth of the larger side of the copy's box of its own ([`SAME`]). +//! A reflected copy is another shape. With Size, a rotated copy has the same size when it is the +//! selected object rotated without scaling: its width and height are compared as they were +//! before the rotation. //! - Selecting one of the repeated objects with the Selection tool highlights the similar objects //! at once, before Global Edit starts. Here they are the objects Start Global Edit would take //! with the current Global Edit Options ([`similar`]). @@ -29,15 +43,16 @@ //! //! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the //! object that was selected, so that the Transform panel and the bounding box measure that object -//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps -//! the first object's outline onto its own. After every edit ([`propagate`], run inside -//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit -//! changed is the model: what changed about it is given to every other member, and nothing else, -//! so members found by shape alone keep their own fills and strokes where the edit didn't touch -//! them: +//! and not the whole set. Each member keeps the transform (a move, a uniform scale and, for a +//! rotated copy, a turn) that maps the first object's outline onto its own. After every edit +//! ([`propagate`], run inside [`Session::edit`], so the whole change is one undo step), the first +//! selected member the edit changed is the model: what changed about it is given to every other +//! member, and nothing else, so members found by shape alone keep their own fills and strokes +//! where the edit didn't touch them: //! //! - its outline (and anchors), placed through that transform, so in proportion to each member's -//! size. Only geometry is placed so: no stroke weight is scaled to a member's size; +//! size and turned by its angle. Only geometry is placed so: no stroke weight is scaled to a +//! member's size; //! - its opacity, blending, knockout and the object's effects, each when it changed; //! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending, //! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the @@ -74,12 +89,12 @@ //! and later edits leave it as it is. //! The object the edit started from can't be left out (a Shift-click on it selects it or //! deselects it as before), Global Edit ends when fewer than two members are left, and leaving -//! one out is not an undo step. Rotated copies are not covered. +//! one out is not an undo step. use serde::Serialize; use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer}; -use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape, Vec2}; use super::wand::{fill_of, stroke_of}; use super::{CommandSpec, Result, Session, bad, bool_or, checked_id, cmd, has_doc}; @@ -98,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> { "Start Global Edit", ["Select"], None, - "{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object (a path, compound path, symbol instance or group of them), with the objects of the same shape (for a group: the groups holding objects like its own, at the same places) (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard), at most 10000 → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}", + "{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object (a path, compound path, symbol instance or group of them), with the objects of the same shape, moved, scaled or rotated copies of it and not reflected ones (for a group: the groups holding objects like its own, at the same places) (and, with `appearance`, the same fill and stroke; with `size`, the same width and height, for a rotated copy before its rotation) on the artboards given (and, with `includeCanvas`, off every artboard), at most 10000 → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}", can_toggle, toggle ), @@ -181,7 +196,8 @@ pub type Outlines = Vec<(BezPath, [u8; 3])>; #[derive(Clone, Debug, PartialEq)] pub struct GlobalEdit { /// The objects edited together, the one selected at the start first, each with the transform - /// (a move and a uniform scale) that maps that first object's outline onto its own. + /// (a move, a uniform scale and, for a turned copy, a turn) that maps that first object's + /// outline onto its own. pub members: Vec<(NodeId, Affine)>, /// [`highlights`] as of a document revision: the canvas asks every frame. outlines: std::cell::RefCell<Option<(u64, Outlines)>>, @@ -257,14 +273,60 @@ impl Form { } } - fn same(&self, o: &Form) -> bool { - self.tag == o.tag - && self.symbols == o.symbols - && self.points.len() == o.points.len() - && self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6) + /// The transform that lays this form's object on `o`'s, point for point in order, and whether + /// it turns the object: a move and a uniform scale when the two lie the same way, otherwise a + /// turn too. `None` when `o` is another shape, a reflected copy included. + fn onto(&self, o: &Form) -> Option<(Affine, bool)> { + if self.tag != o.tag || self.symbols != o.symbols || self.points.len() != o.points.len() { + return None; + } + let near = |xf: Affine| self.points.iter().zip(&o.points).all(|(a, b)| (xf * *a).distance(*b) < SAME); + let back = self.frame.inverse(); + if near(Affine::IDENTITY) { + return Some((o.frame * back, false)); + } + let turn = self.turn_onto(o)?; + near(turn).then(|| (o.frame * turn * back, true)) + } + + /// The turn, uniform scale and move (both forms in their unit boxes) that best lays this + /// form's points on `o`'s in order, by least squares. A turn within [`SQUARE`] of a quarter + /// turn is that quarter turn exactly. + fn turn_onto(&self, o: &Form) -> Option<Affine> { + let n = self.points.len() as f64; + let middle = |f: &Form| f.points.iter().fold(Vec2::ZERO, |s, p| s + p.to_vec2()) / n; + let (from, to) = (middle(self), middle(o)); + // With the points as complex numbers about their middles, the fit is `Σ conj(p) q / Σ |p|²`. + let (mut re, mut im, mut spread) = (0.0, 0.0, 0.0); + for (a, b) in self.points.iter().zip(&o.points) { + let (p, q) = (a.to_vec2() - from, b.to_vec2() - to); + re += p.dot(q); + im += p.cross(q); + spread += p.hypot2(); + } + let len = re.hypot(im); + let scale = len / spread; + if !(scale.is_finite() && scale > 0.0) { + return None; + } + let (mut cos, mut sin) = (re / len, im / len); + if cos.abs() < SQUARE { + (cos, sin) = (0.0, sin.signum()); + } else if sin.abs() < SQUARE { + (cos, sin) = (cos.signum(), 0.0); + } + let (a, b) = (scale * cos, scale * sin); + Some(Affine::translate(to) * Affine::new([a, b, -b, a, 0.0, 0.0]) * Affine::translate(-from)) } } +/// How far apart two forms' points may lie, each form in its unit box, for the forms to be the +/// same. For a turned copy this bounds the angle too: a copy turned and then changed by more than +/// a millionth of its larger side is another shape. +const SAME: f64 = 1e-6; +/// A turn whose sine or cosine is below this is taken as a whole number of quarter turns. +const SQUARE: f64 = 1e-9; + /// Global Edit supports paths, compound paths, symbol instances and groups of them; not images, /// type, linked files, objects with an opacity mask, clipping paths or guides (the help page lists /// images, text, masks and links as unsupported). @@ -395,8 +457,16 @@ fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> { } boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b))) }; + // A copy that lies the same way has the size of the selected object when their boxes are as + // wide and as high; a turned copy, when the transform that lays the object on it doesn't scale. + let same_size = |n: &Node, xf: Affine, turned: bool| { + if turned { + close(xf.determinant().abs().sqrt(), 1.0) + } else { + matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2)) + } + }; let mut out = vec![(*id, Affine::IDENTITY)]; - let back = form.frame.inverse(); super::select::walk_selectable(d, st.isolation, |n| { if n.id == *id || out.len() >= MAX_MEMBERS { return false; @@ -413,12 +483,12 @@ fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> { }; // The object count first: it turns most objects down before their form is made. if n.children().map(Vec::len) == count - && let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n)) - && (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2))) + && let Some((xf, turned)) = Form::of(n).and_then(|f| form.onto(&f)).filter(|_| supported(n)) + && (!o.size || same_size(n, xf, turned)) && (!o.appearance || same_look(key, n)) && in_scope(n) { - out.push((n.id, f.frame * back)); + out.push((n.id, xf)); // A group taken is taken whole, as compound paths, symbol instances and the others are. return false; } @@ -464,7 +534,7 @@ fn toggle(s: &mut Session, p: &Value) -> Result<Value> { let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?; let members = matches(s, &o)?; if members.len() < 2 { - return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again")); + return Err(bad(C, "no similar objects found (reflected copies are not matched); change the Global Edit Options and try again")); } let ids: Vec<u64> = members.iter().map(|(id, _)| id.0).collect(); let st = s.doc_mut()?; @@ -1027,6 +1097,38 @@ mod tests { assert!(taken > 100 && checked - taken > 100 && asked > 200, "{taken} of {checked}, {asked} selected"); } + #[test] + fn a_form_is_laid_on_a_turned_copy_and_on_no_reflected_or_unmeasurable_one() { + let mut d = Document::new(800.0, 600.0); + let mut path = |points: &[(f64, f64)]| { + let anchors = points.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(); + Node::path(d.alloc_id(), PathData::new(vec![SubPath::new(anchors, true)]), Appearance::default()) + }; + let form = Form::of(&path(&[(0.0, 0.0), (30.0, 0.0), (30.0, 70.0)])).unwrap(); + let mut onto = |points: &[(f64, f64)]| Form::of(&path(points)).and_then(|f| form.onto(&f)); + let near = |xf: Affine, want: [f64; 6]| xf.as_coeffs().iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9); + // The same way at twice the size: a move and a scale, no turn. + let (xf, turned) = onto(&[(100.0, 0.0), (160.0, 0.0), (160.0, 140.0)]).unwrap(); + assert!(!turned && near(xf, [2.0, 0.0, 0.0, 2.0, 100.0, 0.0]), "{xf:?}"); + // A quarter turn, (x, y) → (−y, x), moved by (100, 0); and a turn by 30° at half the size. + let (xf, turned) = onto(&[(100.0, 0.0), (100.0, 30.0), (30.0, 30.0)]).unwrap(); + assert!(turned && near(xf, [0.0, 1.0, -1.0, 0.0, 100.0, 0.0]), "{xf:?}"); + let by = Affine::rotate(30f64.to_radians()) * Affine::scale(0.5); + let at = |x: f64, y: f64| { + let p = by * Point::new(x, y); + (p.x, p.y) + }; + let (xf, turned) = onto(&[at(0.0, 0.0), at(30.0, 0.0), at(30.0, 70.0)]).unwrap(); + assert!(turned && near(xf, by.as_coeffs()), "{xf:?}"); + // A reflected copy, a copy with an anchor a thousandth of its size away, and points that + // are not numbers or not finite. + assert!(onto(&[(0.0, 0.0), (-30.0, 0.0), (-30.0, 70.0)]).is_none()); + assert!(onto(&[at(0.0, 0.0), at(30.0, 0.0), at(30.0, 70.07)]).is_none()); + assert!(onto(&[(f64::NAN, 0.0), (30.0, 0.0), (30.0, 70.0)]).is_none()); + assert!(onto(&[(0.0, 0.0), (f64::INFINITY, 0.0), (30.0, 70.0)]).is_none()); + assert!(onto(&[(0.0, 0.0), (30.0, 0.0)]).is_none(), "another count of anchors"); + } + #[test] fn support_of_empty_degenerate_and_deep_objects_is_decided_as_building_the_form_decides() { let mut d = Document::new(800.0, 600.0); diff --git a/crates/engine/src/tests/globaledit.rs b/crates/engine/src/tests/globaledit.rs index fa86248cdb..9a4cb7d138 100644 --- a/crates/engine/src/tests/globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_geom::{Affine, PathData, Rect}; +use vectorcraft_geom::{Affine, PathData, Point, Rect}; use crate::*; @@ -320,16 +320,16 @@ fn global_edit_needs_one_supported_object() { let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); sel(&mut s, &[t]); assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type"); - let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0); - // A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the - // message says so. - let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0); - sel(&mut s, &[turned]); - s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + // A reflected copy is not taken, and the message says so. + let lone = kite(&mut s, 0.0, 500.0, 1.0); + let mirrored = kite(&mut s, 200.0, 500.0, 1.0); + sel(&mut s, &[mirrored]); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); sel(&mut s, &[lone]); let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string(); - assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}"); + assert!(e.contains("no similar objects") && e.contains("reflected copies are not matched") && !e.contains("rotated"), "{e}"); assert!(s.doc().unwrap().global_edit.is_none()); + assert!(crate::cmd::globaledit::highlights(&s).is_empty(), "and it is not highlighted"); // A group holding type is not offered, though another such group exists. let group = |s: &mut Session, x: f64| { let p = rect(s, x, 100.0, 10.0, 10.0); @@ -851,3 +851,159 @@ fn groups_nested_as_deep_as_a_document_allows_are_edited_together() { s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); assert_eq!(fill(&s, other_inner), "#12ab34"); } + +/// A four-sided path with no symmetry, `k` times the size, its box's top left near (`x`, `y`): a +/// 30 × 70 rectangle whose first anchor is moved. +fn kite(s: &mut Session, x: f64, y: f64, k: f64) -> NodeId { + let id = rect(s, x, y, 30.0 * k, 70.0 * k); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 10.0 * k, "dy": -20.0 * k})).unwrap(); + id +} + +/// Rotate `id` by `deg` (counter-clockwise) about the centre of its box → that rotation. +fn rotate(s: &mut Session, id: NodeId, deg: f64) -> Affine { + let c = bounds(s, id).center(); + sel(s, &[id]); + s.execute("object.rotate", &json!({"angle": deg})).unwrap(); + Affine::rotate_about(-deg.to_radians(), c) +} + +/// A kite at the origin side of the page, a copy of it turned a quarter turn, and a copy at half +/// the size turned by 30° → the three and the transforms that lay the first on the other two. +fn turned_kites(s: &mut Session) -> ([NodeId; 3], [Affine; 2]) { + let a = kite(s, 0.0, 100.0, 1.0); + let (b, c) = (kite(s, 200.0, 100.0, 1.0), kite(s, 400.0, 100.0, 0.5)); + let quarter = rotate(s, b, 90.0) * Affine::translate((200.0, 0.0)); + let small = rotate(s, c, 30.0) * Affine::translate((400.0, 100.0)) * Affine::scale(0.5) * Affine::translate((0.0, -100.0)); + assert!(placed(s, a, b, quarter) && placed(s, a, c, small), "the copies as made"); + ([a, b, c], [quarter, small]) +} + +#[test] +fn start_global_edit_takes_rotated_copies_and_an_edit_reaches_each_turned_by_its_angle() { + let mut s = session(); + let ([a, b, c], [quarter, small]) = turned_kites(&mut s); + sel(&mut s, &[a]); + assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "both are highlighted before the start"); + assert_eq!(s.execute("select.globalEditOptions", &json!({})).unwrap()["count"], 3); + assert_eq!(start(&mut s, a, json!({})), [a, b, c]); + let before = (*s.doc().unwrap().doc).clone(); + let steps = s.doc().unwrap().history.undo.len(); + // An anchor moved on the selected kite moves on each copy along the copy's own directions. + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 12, "dy": 5})).unwrap(); + assert!(placed(&s, a, b, quarter), "the quarter-turned copy"); + assert!(placed(&s, a, c, small), "the smaller copy turned by 30°"); + assert!(!placed(&s, a, b, Affine::translate((200.0, 0.0))), "the copy is still turned"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + crate::tests_large_edits::undo_gives_back(&mut s, &before); + // A move of the whole kite to the right moves the quarter-turned copy up (counter-clockwise). + sel(&mut s, &[a]); + let was = bounds(&s, b); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + let now = bounds(&s, b); + assert!((now.x0 - was.x0).abs() < 1e-6 && (now.y0 - (was.y0 - 10.0)).abs() < 1e-6, "{was:?} → {now:?}"); + assert!(placed(&s, a, b, quarter) && placed(&s, a, c, small), "each copy lies on the kite as before"); +} + +#[test] +fn a_rotated_copy_leads_an_edit_as_any_other_member_does() { + let mut s = session(); + let ([a, b, c], [quarter, small]) = turned_kites(&mut s); + // Started on the turned copy, and edited from the smaller one. + assert_eq!(start(&mut s, b, json!({})), [b, a, c]); + s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 2]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 3, "dy": -4})).unwrap(); + assert!(placed(&s, a, b, quarter) && placed(&s, a, c, small)); + // A fill reaches the turned copies as it is. + s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); + assert_eq!((fill(&s, a), fill(&s, b)), ("#12ab34".to_string(), "#12ab34".to_string())); +} + +#[test] +fn size_takes_a_rotated_copy_of_the_same_size_and_other_shapes_stay_out() { + let mut s = session(); + let ([a, b, c], _) = turned_kites(&mut s); + // A quarter-turned copy is 70 × 30 by its box: it has the size of the 30 × 70 kite all the same. + assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "not the half-size copy {c}"); + s.execute("select.globalEdit", &json!({})).unwrap(); + // A copy turned and then stretched, and one whose anchor was moved, are other shapes. + let stretched = kite(&mut s, 0.0, 300.0, 1.0); + rotate(&mut s, stretched, 30.0); + s.execute("object.scale", &json!({"sx": 150, "sy": 100})).unwrap(); + let bent = kite(&mut s, 200.0, 300.0, 1.0); + rotate(&mut s, bent, 45.0); + s.execute("select.anchors", &json!({"id": bent.0, "anchors": [[0, 2]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 1, "dy": 0})).unwrap(); + // A copy turned by half a turn and by a full turn in two steps. + let half = kite(&mut s, 400.0, 300.0, 1.0); + rotate(&mut s, half, 180.0); + let full = kite(&mut s, 600.0, 300.0, 1.0); + rotate(&mut s, full, 135.0); + rotate(&mut s, full, 225.0); + assert_eq!(start(&mut s, a, json!({})), [a, b, c, half, full], "not the stretched {stretched} nor the bent {bent}"); +} + +#[test] +fn a_square_whose_anchors_were_turned_is_a_rotated_copy() { + let mut s = session(); + let [a, b, ..] = squares(&mut s); + let turn = rotate(&mut s, b, 90.0) * Affine::translate((300.0, 0.0)); + assert_eq!(start(&mut s, a, json!({})).len(), 3, "the turned square is still taken, with the small one"); + // The first anchor of the turned square lies at another corner, and that is the one that moves. + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap(); + assert!(placed(&s, a, b, turn)); + assert!(!placed(&s, a, b, Affine::translate((300.0, 0.0)))); +} + +#[test] +fn rotated_groups_are_like_the_group_and_an_edit_inside_one_reaches_the_others_turned() { + let mut s = session(); + let (g, [sq, dot, _]) = card(&mut s, 0.0, 1.0); + let (copy, [csq, cdot, cbar]) = card(&mut s, 300.0, 1.0); + let turn = rotate(&mut s, copy, 90.0) * Affine::translate((300.0, 0.0)); + // The same three objects, each turned in place: not the group turned as a whole. + let (apart, objects) = card(&mut s, 500.0, 1.0); + for id in objects { + rotate(&mut s, id, 90.0); + } + assert_eq!(start(&mut s, g, json!({})), [g, copy], "not the group {apart} whose objects were turned one by one"); + sel(&mut s, &[dot]); + s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); + assert_eq!(fill(&s, cdot), "#12ab34"); + sel(&mut s, &[sq]); + s.execute("object.setBounds", &json!({"height": 20, "reference": 0})).unwrap(); + assert!(placed(&s, sq, csq, turn), "the square in the turned group is halved along the group's own direction"); + assert_eq!(kids(&s, copy), [csq, cdot, cbar], "each object keeps its id"); + s.execute("select.globalEdit", &json!({})).unwrap(); + // An object inside one group takes the object at its place in the turned group, as the + // objects of rotated copies of a group are edited together. + assert_eq!(start(&mut s, sq, json!({})), [sq, csq]); +} + +#[test] +fn a_rotated_symbol_instance_is_taken_and_a_reflected_one_is_not() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + let first = NodeId(s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap()["id"].as_u64().unwrap()); + let place = + |s: &mut Session, x: f64| NodeId(s.execute("symbol.place", &json!({"name": "Tile", "x": x, "y": 300})).unwrap()["id"].as_u64().unwrap()); + let (turned, mirrored) = (place(&mut s, 200.0), place(&mut s, 400.0)); + sel(&mut s, &[turned]); + s.execute("object.rotate", &json!({"angle": 30, "origin": [220, 320]})).unwrap(); + sel(&mut s, &[mirrored]); + s.execute("object.reflect", &json!({"axis": "vertical", "origin": [420, 320]})).unwrap(); + assert_eq!(start(&mut s, first, json!({})), [first, turned], "not the reflected instance {mirrored}"); + let xf = |s: &Session, id: NodeId| match node(s, id).kind { + NodeKind::SymbolInstance { xf, .. } => xf, + _ => panic!("not an instance"), + }; + let was = xf(&s, turned); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + // The instance turned by 30° counter-clockwise moves along its own direction. + let moved = Affine::rotate((-30f64).to_radians()) * Point::new(10.0, 0.0); + let (now, want) = (xf(&s, turned).as_coeffs(), (Affine::translate(moved.to_vec2()) * was).as_coeffs()); + assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}"); +} diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..cdaebd3bf1 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6170,7 +6170,7 @@ axis Y Axis Y osa @plural Result: {n}|Results: {n} Rezultat: {n}|Rezultati: {n}|Rezultati: {n} @msg select one object odaberite jedan objekat @msg Global Edit works on paths, compound paths, symbol instances and groups of them Globalno uređivanje radi na putanjama, složenim putanjama, instancama simbola i njihovim grupama -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nisu pronađeni slični objekti (rotirane ili zrcaljene kopije se ne podudaraju); promijenite opcije globalnog uređivanja i pokušajte ponovo +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nisu pronađeni slični objekti (zrcaljene kopije se ne podudaraju); promijenite opcije globalnog uređivanja i pokušajte ponovo @msg Global Edit is off Globalno uređivanje je isključeno @msg only an object of the Global Edit other than the one it started from can be left out iz globalnog uređivanja može se isključiti samo objekt koji nije onaj od kojeg je počelo @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ili raspon poput 1, 3-5, a ne {v} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..3f631efd9c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5866,7 +5866,7 @@ digits Malayalam Malaiàlam @msg no segment {segment} on line {line} no hi ha el segment {segment} a la línia {line} @msg no selected slice in the image: select slices, or save all of them no hi ha cap tall seleccionat a la imatge: seleccioneu talls o deseu-los tots @msg no selection bounds no hi ha límits de selecció -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again no s’ha trobat cap objecte similar (les còpies girades o reflexades no coincideixen); canvieu les opcions d’edició global i torneu-ho a provar +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again no s’ha trobat cap objecte similar (les còpies reflexades no coincideixen); canvieu les opcions d’edició global i torneu-ho a provar @msg Global Edit is off L’edició global no està activa @msg only an object of the Global Edit other than the one it started from can be left out només es pot excloure un objecte de l’edició global que no sigui aquell amb què va començar @msg no slice selected no hi ha cap tall seleccionat diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..71e2c0afc0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6674,7 +6674,7 @@ axis Y Axis osa Y @plural Result: {n}|Results: {n} Výsledek: {n}|Výsledky: {n}|Výsledky: {n} @msg select one object vyberte jeden objekt @msg Global Edit works on paths, compound paths, symbol instances and groups of them Globální úpravy pracují s cestami, složenými cestami, instancemi symbolů a jejich skupinami -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nebyly nalezeny žádné podobné objekty (otočené a zrcadlené kopie se nevyhledávají); změňte možnosti globálních úprav a zkuste to znovu +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nebyly nalezeny žádné podobné objekty (zrcadlené kopie se nevyhledávají); změňte možnosti globálních úprav a zkuste to znovu @msg Global Edit is off Globální úpravy nejsou zapnuté @msg only an object of the Global Edit other than the one it started from can be left out z globálních úprav lze vyřadit jen jiný objekt než ten, od kterého začaly @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all nebo rozsah, například 1, 3-5, ne {v} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..3a97954267 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6266,7 +6266,7 @@ axis Y Axis Y-Achse @plural Result: {n}|Results: {n} Ergebnis: {n}|Ergebnisse: {n} @msg select one object wählen Sie ein Objekt aus @msg Global Edit works on paths, compound paths, symbol instances and groups of them Globale Bearbeitung funktioniert mit Pfaden, zusammengesetzten Pfaden, Symbolinstanzen und Gruppen daraus -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again keine ähnlichen Objekte gefunden (gedrehte oder gespiegelte Kopien werden nicht erkannt); ändern Sie die Optionen für globale Bearbeitung und versuchen Sie es erneut +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again keine ähnlichen Objekte gefunden (gespiegelte Kopien werden nicht erkannt); ändern Sie die Optionen für globale Bearbeitung und versuchen Sie es erneut @msg Global Edit is off Globale Bearbeitung ist nicht aktiv @msg only an object of the Global Edit other than the one it started from can be left out nur ein Objekt der globalen Bearbeitung außer dem, mit dem sie begonnen hat, kann ausgeschlossen werden @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all oder ein Bereich wie 1, 3-5, nicht {v} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..78e8285c1b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6626,7 +6626,7 @@ axis Y Axis Άξονας Y @plural Result: {n}|Results: {n} Αποτέλεσμα: {n}|Αποτελέσματα: {n} @msg select one object επιλέξτε ένα αντικείμενο @msg Global Edit works on paths, compound paths, symbol instances and groups of them Η καθολική επεξεργασία λειτουργεί σε διαδρομές, σύνθετες διαδρομές, παρουσίες συμβόλων και ομάδες τους -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again δεν βρέθηκαν παρόμοια αντικείμενα (τα περιστραμμένα ή κατοπτρισμένα αντίγραφα δεν εντοπίζονται)· αλλάξτε τις επιλογές καθολικής επεξεργασίας και δοκιμάστε ξανά +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again δεν βρέθηκαν παρόμοια αντικείμενα (τα κατοπτρισμένα αντίγραφα δεν εντοπίζονται)· αλλάξτε τις επιλογές καθολικής επεξεργασίας και δοκιμάστε ξανά @msg Global Edit is off Η καθολική επεξεργασία δεν είναι ενεργή @msg only an object of the Global Edit other than the one it started from can be left out μπορεί να εξαιρεθεί μόνο ένα αντικείμενο της καθολικής επεξεργασίας εκτός από εκείνο με το οποίο ξεκίνησε @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ή ένα εύρος όπως 1, 3-5, όχι {v} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..9132b8ea30 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6617,7 +6617,7 @@ axis Y Axis Eje Y @plural Result: {n}|Results: {n} Resultado: {n}|Resultados: {n} @msg select one object seleccione un objeto @msg Global Edit works on paths, compound paths, symbol instances and groups of them La edición global funciona con trazados, trazados compuestos, instancias de símbolos y grupos de ellos -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again no se encontraron objetos similares (las copias giradas o reflejadas no coinciden); cambie las opciones de edición global e inténtelo de nuevo +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again no se encontraron objetos similares (las copias reflejadas no coinciden); cambie las opciones de edición global e inténtelo de nuevo @msg Global Edit is off La edición global no está activa @msg only an object of the Global Edit other than the one it started from can be left out solo se puede excluir un objeto de la edición global distinto de aquel con el que empezó @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervalo como 1, 3-5, no {v} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..f847330a6d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6704,7 +6704,7 @@ axis Y Axis Y-akseli @plural Result: {n}|Results: {n} Tulos: {n}|Tulokset: {n} @msg select one object valitse yksi objekti @msg Global Edit works on paths, compound paths, symbol instances and groups of them Yleismuokkaus toimii poluille, yhdistelmäpoluille, symbolien esiintymille ja niiden ryhmille -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again samankaltaisia objekteja ei löytynyt (kierrettyjä tai peilattuja kopioita ei tunnisteta); muuta yleismuokkauksen asetuksia ja yritä uudelleen +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again samankaltaisia objekteja ei löytynyt (peilattuja kopioita ei tunnisteta); muuta yleismuokkauksen asetuksia ja yritä uudelleen @msg Global Edit is off Yleismuokkaus ei ole käynnissä @msg only an object of the Global Edit other than the one it started from can be left out yleismuokkauksesta voi jättää pois vain muun objektin kuin sen, josta se alkoi @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all tai alue, kuten 1, 3-5, ei {v} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..a32307544b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6669,7 +6669,7 @@ axis Y Axis Axe Y @plural Result: {n}|Results: {n} Résultat : {n}|Résultats : {n} @msg select one object sélectionnez un seul objet @msg Global Edit works on paths, compound paths, symbol instances and groups of them La modification globale s’applique aux tracés, aux tracés transparents, aux occurrences de symboles et aux groupes de ces objets -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again aucun objet similaire trouvé (les copies pivotées ou symétriques ne sont pas reconnues) ; modifiez les options de modification globale et réessayez +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again aucun objet similaire trouvé (les copies symétriques ne sont pas reconnues) ; modifiez les options de modification globale et réessayez @msg Global Edit is off La modification globale n’est pas active @msg only an object of the Global Edit other than the one it started from can be left out seul un objet de la modification globale autre que celui à partir duquel elle a commencé peut être exclu @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards` : all ou une plage telle que 1, 3-5, et non {v} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..a80622e738 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7413,7 +7413,7 @@ antiAlias Sharp शार्प @msg no selected slice in the image: select slices, or save all of them इमेज में कोई सिलेक्टेड स्लाइस नहीं: स्लाइस सिलेक्ट करें, या सभी स्लाइस सेव करें @msg no selection bounds कोई सिलेक्शन सीमा नहीं @msg no series {i} कोई श्रृंखला {i} -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again कोई समान ऑब्जेक्ट नहीं पाया (घूर्णित या परिलक्षित प्रतियां मिलान नहीं की जाती हैं); वैश्विक संपादन ऑप्शन्स बदलें और फिर से प्रयास करें +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again कोई समान ऑब्जेक्ट नहीं पाया (परिलक्षित प्रतियां मिलान नहीं की जाती हैं); वैश्विक संपादन ऑप्शन्स बदलें और फिर से प्रयास करें @msg no slice {n} in the image (see slice.list) इमेज में कोई स्लाइस {n} नहीं है (slice.list देखें) @msg no slice selected सिलेक्टेड कोई स्लाइस नहीं @msg no source art कोई स्रोत कला diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7f2f03afb2..832e99b3b5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6657,7 +6657,7 @@ axis Y Axis Y tengely @plural Result: {n}|Results: {n} Eredmény: {n} @msg select one object jelöljön ki egy objektumot @msg Global Edit works on paths, compound paths, symbol instances and groups of them A globális szerkesztés útvonalakon, összetett útvonalakon, szimbólumpéldányokon és ezek csoportjain működik -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nem található hasonló objektum (az elforgatott vagy tükrözött másolatok nem számítanak egyezésnek); módosítsa a globális szerkesztés beállításait, és próbálja újra +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nem található hasonló objektum (a tükrözött másolatok nem számítanak egyezésnek); módosítsa a globális szerkesztés beállításait, és próbálja újra @msg Global Edit is off A globális szerkesztés nincs bekapcsolva @msg only an object of the Global Edit other than the one it started from can be left out a globális szerkesztésből csak a kiindulási objektumtól eltérő objektum zárható ki @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all vagy egy tartomány, például 1, 3-5, nem {v} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f832a5baf3..e0197fe98f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6300,7 +6300,7 @@ axis Y Axis Asse Y @plural Result: {n}|Results: {n} Risultato: {n}|Risultati: {n} @msg select one object selezionate un oggetto @msg Global Edit works on paths, compound paths, symbol instances and groups of them La modifica globale funziona su tracciati, tracciati composti, istanze di simboli e gruppi di essi -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nessun oggetto simile trovato (le copie ruotate o riflesse non vengono riconosciute); cambiate le opzioni di modifica globale e riprovate +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nessun oggetto simile trovato (le copie riflesse non vengono riconosciute); cambiate le opzioni di modifica globale e riprovate @msg Global Edit is off La modifica globale non è attiva @msg only an object of the Global Edit other than the one it started from can be left out si può escludere solo un oggetto della modifica globale diverso da quello da cui è partita @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervallo come 1, 3-5, non {v} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3abb388f49..8396c7beab 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6366,7 +6366,7 @@ axis Y Axis Y 軸 @plural Result: {n}|Results: {n} 結果: {n} @msg select one object オブジェクトを 1 つ選択してください @msg Global Edit works on paths, compound paths, symbol instances and groups of them グローバル編集はパス、複合パス、シンボルインスタンスとそれらのグループに使用できます -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 類似するオブジェクトが見つかりません(回転または反転したコピーは一致しません)。グローバル編集オプションを変更してもう一度試してください +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again 類似するオブジェクトが見つかりません(反転したコピーは一致しません)。グローバル編集オプションを変更してもう一度試してください @msg Global Edit is off グローバル編集はオフです @msg only an object of the Global Edit other than the one it started from can be left out 除外できるのは、グローバル編集を開始したオブジェクト以外のオブジェクトだけです @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all または 1, 3-5 のような範囲を指定してください ({v} ではなく) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..e60b5bb753 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6625,7 +6625,7 @@ axis Y Axis Y ღერძი @plural Result: {n}|Results: {n} შედეგი: {n} @msg select one object მონიშნეთ ერთი ობიექტი @msg Global Edit works on paths, compound paths, symbol instances and groups of them გლობალური რედაქტირება მუშაობს ტრაექტორიებზე, შედგენილ ტრაექტორიებზე, სიმბოლოს ეგზემპლარებსა და მათ ჯგუფებზე -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again მსგავსი ობიექტები ვერ მოიძებნა (შემობრუნებული ან არეკლილი ასლები არ ემთხვევა); შეცვალეთ გლობალური რედაქტირების პარამეტრები და სცადეთ ხელახლა +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again მსგავსი ობიექტები ვერ მოიძებნა (არეკლილი ასლები არ ემთხვევა); შეცვალეთ გლობალური რედაქტირების პარამეტრები და სცადეთ ხელახლა @msg Global Edit is off გლობალური რედაქტირება გამორთულია @msg only an object of the Global Edit other than the one it started from can be left out გლობალური რედაქტირებიდან შეიძლება გამოირიცხოს მხოლოდ ობიექტი, რომლითაც ის არ დაწყებულა @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ან დიაპაზონი, მაგალითად 1, 3-5, და არა {v} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..edba9d6cc1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6261,7 +6261,7 @@ axis Y Axis Y축 @plural Result: {n}|Results: {n} 결과: {n} @msg select one object 개체 하나를 선택하십시오 @msg Global Edit works on paths, compound paths, symbol instances and groups of them 전체 편집은 패스, 컴파운드 패스, 심볼 인스턴스 및 이들의 그룹에서 작동합니다 -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 유사한 개체를 찾을 수 없습니다(회전하거나 반사한 복사본은 일치하지 않습니다). 전체 편집 옵션을 변경한 후 다시 시도하십시오 +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again 유사한 개체를 찾을 수 없습니다(반사한 복사본은 일치하지 않습니다). 전체 편집 옵션을 변경한 후 다시 시도하십시오 @msg Global Edit is off 전체 편집이 꺼져 있습니다 @msg only an object of the Global Edit other than the one it started from can be left out 전체 편집을 시작한 개체가 아닌 전체 편집의 개체만 제외할 수 있습니다 @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all 또는 1, 3-5 같은 범위여야 합니다({v}이(가) 아님) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..af98d0816e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -475,7 +475,7 @@ @msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed de module heeft niet de SHA-256 die `hash` opgeeft: er is niets geïnstalleerd @msg select one object selecteer één object -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again geen vergelijkbare objecten gevonden (gedraaide of gespiegelde kopieën worden niet herkend); wijzig de Opties voor globale bewerking en probeer het opnieuw +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again geen vergelijkbare objecten gevonden (gespiegelde kopieën worden niet herkend); wijzig de Opties voor globale bewerking en probeer het opnieuw @msg Global Edit is off Globale bewerking staat uit @msg only an object of the Global Edit other than the one it started from can be left out alleen een ander object van de globale bewerking dan het object waarmee die begon, kan worden weggelaten @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all of een bereik zoals 1, 3-5, niet {v} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..cde8490eeb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6529,7 +6529,7 @@ axis Y Axis Oś Y @plural Result: {n}|Results: {n} Wynik: {n}|Wyniki: {n}|Wyniki: {n} @msg select one object zaznacz jeden obiekt @msg Global Edit works on paths, compound paths, symbol instances and groups of them Edycja globalna działa na ścieżkach, ścieżkach złożonych, wystąpieniach symboli i ich grupach -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nie znaleziono podobnych obiektów (obrócone i odbite kopie nie są dopasowywane); zmień opcje edycji globalnej i spróbuj ponownie +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nie znaleziono podobnych obiektów (odbite kopie nie są dopasowywane); zmień opcje edycji globalnej i spróbuj ponownie @msg Global Edit is off Edycja globalna jest wyłączona @msg only an object of the Global Edit other than the one it started from can be left out z edycji globalnej można wykluczyć tylko obiekt inny niż ten, od którego się zaczęła @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all lub zakres, np. 1, 3-5, a nie {v} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..a89d1389f9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6589,7 +6589,7 @@ axis Y Axis Eixo Y @plural Result: {n}|Results: {n} Resultado: {n}|Resultados: {n} @msg select one object selecione um objeto @msg Global Edit works on paths, compound paths, symbol instances and groups of them A edição global funciona com demarcadores, demarcadores compostos, instâncias de símbolos e grupos deles -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nenhum objeto semelhante encontrado (cópias giradas ou refletidas não são correspondidas); altere as opções de edição global e tente novamente +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nenhum objeto semelhante encontrado (cópias refletidas não são correspondidas); altere as opções de edição global e tente novamente @msg Global Edit is off A edição global está desativada @msg only an object of the Global Edit other than the one it started from can be left out só é possível deixar fora da edição global um objeto diferente daquele com que ela começou @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ou um intervalo como 1, 3-5, não {v} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..9e0aa4f518 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6285,7 +6285,7 @@ axis Y Axis Ось Y @plural Result: {n}|Results: {n} Результат: {n}|Результаты: {n}|Результаты: {n} @msg select one object выберите один объект @msg Global Edit works on paths, compound paths, symbol instances and groups of them Глобальное редактирование работает с контурами, составными контурами, экземплярами символов и их группами -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again похожие объекты не найдены (повёрнутые или отражённые копии не учитываются); измените параметры глобального редактирования и повторите попытку +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again похожие объекты не найдены (отражённые копии не учитываются); измените параметры глобального редактирования и повторите попытку @msg Global Edit is off Глобальное редактирование выключено @msg only an object of the Global Edit other than the one it started from can be left out исключить из глобального редактирования можно только объект, отличный от того, с которого оно началось @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all или диапазон, например 1, 3-5, а не {v} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..7e23fcd0e1 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6654,7 +6654,7 @@ axis Y Axis Y-axel @plural Result: {n}|Results: {n} Resultat: {n}|Resultat: {n} @msg select one object markera ett objekt @msg Global Edit works on paths, compound paths, symbol instances and groups of them Global redigering fungerar på banor, sammansatta banor, symbolinstanser och grupper av dem -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again inga liknande objekt hittades (roterade eller speglade kopior matchas inte); ändra alternativen för global redigering och försök igen +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again inga liknande objekt hittades (speglade kopior matchas inte); ändra alternativen för global redigering och försök igen @msg Global Edit is off Global redigering är inte aktiv @msg only an object of the Global Edit other than the one it started from can be left out bara ett annat objekt än det som den globala redigeringen började med kan uteslutas @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all eller ett intervall som 1, 3-5, inte {v} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..4ab7cbcd6a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6747,7 +6747,7 @@ axis Y Axis Y ekseni @plural Result: {n}|Results: {n} Sonuç: {n} @msg select one object bir nesne seçin @msg Global Edit works on paths, compound paths, symbol instances and groups of them Genel düzenleme yollar, bileşik yollar, sembol örnekleri ve bunların grupları üzerinde çalışır -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again benzer nesne bulunamadı (döndürülmüş veya yansıtılmış kopyalar eşleşmez); Genel Düzenleme Seçeneklerini değiştirip yeniden deneyin +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again benzer nesne bulunamadı (yansıtılmış kopyalar eşleşmez); Genel Düzenleme Seçeneklerini değiştirip yeniden deneyin @msg Global Edit is off Genel düzenleme kapalı @msg only an object of the Global Edit other than the one it started from can be left out yalnızca genel düzenlemenin başladığı nesne dışındaki bir nesne düzenlemenin dışında bırakılabilir @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ya da 1, 3-5 gibi bir aralık olmalı, {v} değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..02706d2aa3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6280,7 +6280,7 @@ axis Y Axis Вісь Y @plural Result: {n}|Results: {n} Результат: {n}|Результати: {n}|Результати: {n} @msg select one object виберіть один об’єкт @msg Global Edit works on paths, compound paths, symbol instances and groups of them Глобальне редагування працює з контурами, складеними контурами, екземплярами символів та їхніми групами -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again схожих об’єктів не знайдено (повернуті чи віддзеркалені копії не враховуються); змініть параметри глобального редагування та спробуйте ще раз +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again схожих об’єктів не знайдено (віддзеркалені копії не враховуються); змініть параметри глобального редагування та спробуйте ще раз @msg Global Edit is off Глобальне редагування вимкнено @msg only an object of the Global Edit other than the one it started from can be left out виключити з глобального редагування можна лише об’єкт, відмінний від того, з якого воно почалося @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all або діапазон, наприклад 1, 3-5, а не {v} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..823a1fdb70 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6281,7 +6281,7 @@ axis Y Axis Trục Y @plural Result: {n}|Results: {n} Kết quả: {n} @msg select one object hãy chọn một đối tượng @msg Global Edit works on paths, compound paths, symbol instances and groups of them Chỉnh sửa toàn cục hoạt động trên đường dẫn, đường dẫn phức hợp, phiên bản biểu tượng và nhóm của chúng -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again không tìm thấy đối tượng tương tự (các bản sao đã xoay hoặc lật không được tính); hãy thay đổi tùy chọn chỉnh sửa toàn cục rồi thử lại +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again không tìm thấy đối tượng tương tự (các bản sao đã lật không được tính); hãy thay đổi tùy chọn chỉnh sửa toàn cục rồi thử lại @msg Global Edit is off Chỉnh sửa toàn cục đang tắt @msg only an object of the Global Edit other than the one it started from can be left out chỉ có thể loại khỏi chỉnh sửa toàn cục một đối tượng khác với đối tượng mà nó bắt đầu @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all hoặc một phạm vi như 1, 3-5, không phải {v} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..1fa8a17251 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6513,7 +6513,7 @@ axis Y Axis Y 轴 @plural Result: {n}|Results: {n} 结果:{n} @msg select one object 请选择一个对象 @msg Global Edit works on paths, compound paths, symbol instances and groups of them 全局编辑适用于路径、复合路径、符号实例及其编组 -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 未找到相似对象(旋转或镜像的副本不会匹配);请更改全局编辑选项后重试 +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again 未找到相似对象(镜像的副本不会匹配);请更改全局编辑选项后重试 @msg Global Edit is off 全局编辑未开启 @msg only an object of the Global Edit other than the one it started from can be left out 只能从全局编辑中排除起始对象以外的对象 @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`:应为 all 或 1, 3-5 这样的范围,而不是 {v} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..6ec3e2b043 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6320,7 +6320,7 @@ axis Y Axis Y 軸 @plural Result: {n}|Results: {n} 結果:{n} @msg select one object 請選取一個物件 @msg Global Edit works on paths, compound paths, symbol instances and groups of them 整體編輯適用於路徑、複合路徑、符號實體及其群組 -@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 找不到相似的物件(旋轉或鏡像的副本不會相符);請變更整體編輯選項後再試一次 +@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again 找不到相似的物件(鏡像的副本不會相符);請變更整體編輯選項後再試一次 @msg Global Edit is off 整體編輯未開啟 @msg only an object of the Global Edit other than the one it started from can be left out 只能從整體編輯中排除起始物件以外的物件 @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`:應為 all 或 1, 3-5 這類範圍,而不是 {v} diff --git a/docs/gaps.md b/docs/gaps.md index 1e0dc97609..a352c17cfd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -632,6 +632,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi made to an object inside it at the object in the same place ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); a group that holds type or an image is not taken, as Global Edit does not take images or text ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)); + rotated copies of the object or group are taken too, and an edit reaches each turned by its angle + ([public source](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html); reflected copies are not + taken, VectorCraft's own rule); with one such object or group selected with the Selection tool, the objects Start Global Edit would take are highlighted on the canvas before it starts (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); dynamic symbols (#4938): Symbol Options › Symbol Type (Dynamic Symbol / Static Symbol; `type` on @@ -677,8 +680,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic - Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons and Weather Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; - Global Edit of rotated copies as matches. + Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons and Weather Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write. - **Estimate:** 4–14 h. ## G3 3D and Materials diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..47fa658295 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2771,11 +2771,11 @@ nearest to the pointer. ## Global Edit Select › Start Global Edit is `select.globalEdit {appearance?, size?, artboards?, includeCanvas?}`. With one path, -compound path or symbol instance selected, it finds the objects of the same shape (moved or scaled copies; not rotated -ones), among those `select.all` would take and the objects in their groups, and returns `{active: true, count, ids}`, +compound path or symbol instance selected, it finds the objects of the same shape (moved, scaled or rotated copies; +not reflected ones), among those `select.all` would take and the objects in their groups, and returns `{active: true, count, ids}`, the selected object first (at most 10,000). With a group of such objects selected (nested groups and clipping paths included; not type or images), it finds the groups like it: as many objects, in the same order, each of the same -shape, lying the same way relative to each other (the group moved or scaled as a whole). While Global Edit is off, +shape, lying the same way relative to each other (the group moved, scaled or rotated as a whole). While Global Edit is off, the command is disabled for any other selection, including a group that holds type, an image or nothing (it is off in the Select menu, the Properties panel's Quick Actions do not show it, and the Control bar does not show its Global Edit Options). `appearance` also asks for @@ -2784,7 +2784,9 @@ the same fill and stroke (for groups, of each object in them), `size` for the sa params come from `select.globalEditOptions` (the Global Edit Options, kept for the session; it returns them with `count`, the objects a start would take now). The selection stays the one object. Until the next `select.globalEdit` (Stop Global Edit, `{active: false}`), what an edit of a selected match changed is given to the other matches, in the -same undo step, and nothing else. A changed outline or anchors go to each match in proportion to its size. A changed +same undo step, and nothing else. A changed outline or anchors go to each match in proportion to its size, and to a +rotated copy turned by its angle: an anchor moved to the right on the selected object moves upward on a copy rotated +a quarter turn counter-clockwise. A changed opacity or blending, and each changed setting of a fill or stroke (paint, weight, opacity, blending, effects…), are copied as they are to the match's fill or stroke of the same kind and rank; its other fills and strokes, and their unchanged settings, stay its own (a stroke weight change leaves the fills). A stroke weight set on the selected match @@ -2800,6 +2802,16 @@ nothing. Nothing of it is saved. Before Global Edit starts, while one object tha selected with the Selection tool, the canvas highlights the other objects a start would take with the current Global Edit Options (none while a drag is in progress, or when there are none). +Rotated copies are edited together as in [Design a modern geometric +logo](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html), where copies of a group rotated +around a point are edited at once from one of their objects. These rules for them are VectorCraft's own. A copy's +angle is the one that lays each anchor and handle of the selected object on the copy's anchor and handle of the same +order, so a square whose anchors were turned a quarter turn is a copy rotated a quarter turn. The copy is the same +shape when, laid so, every anchor and handle lies within a millionth of the larger side of the copy's box of its own. A +reflected copy is another shape, and so is a copy stretched along one direction. With `size`, a rotated copy has the +same size when it is the selected object rotated without scaling: a 30 × 70 pt object and its copy rotated a quarter +turn (70 × 30 pt by its box) have the same size. + `select.globalEditExclude {id}` leaves the match `id` out of the Global Edit, as a Shift-click on a highlighted match (or on an object inside it) with the Selection tool does: it is no longer highlighted, and later edits leave it as it is. It returns `{active, count, ids}`, the objects left in the Global Edit; with fewer than two left, the Global Edit diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..909e8fb302 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -646,6 +646,7 @@ since 2026-10-01. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. - **Start Global Edit is offered only for a group it can start on:** with a group selected, Select › Start Global Edit is disabled, and the Properties panel's Quick Actions and the Control bar do not offer it or its options, when the group holds type, an image or nothing, at any depth; the check walks the group's objects the same way, stopping at the first one Global Edit does not take. This is VectorCraft's own rule. Regression test: `start_global_edit_is_offered_only_for_a_group_of_supported_objects` in `crates/engine/src/tests/globaledit.rs`. +- **Global Edit takes rotated copies:** Select › Start Global Edit on an object or group also takes its rotated copies, with the highlights before the start and the options' Results count, and an edit reaches each copy turned by its angle, as copies of a group rotated around a point are edited at once ([Design a modern geometric logo](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html)). Under VectorCraft's own rules, a copy's angle is the one that lays the selected object's anchors and handles on the copy's in order, each within a millionth of the larger side of the copy's box; a reflected copy is not taken; and with Size, a rotated copy has the same size when it is the object rotated without scaling. Regression tests: `start_global_edit_takes_rotated_copies_*`, `a_rotated_copy_leads_*`, `size_takes_a_rotated_copy_*`, `a_square_whose_anchors_were_turned_*`, `rotated_groups_are_like_the_group_*` and `a_rotated_symbol_instance_*` in `crates/engine/src/tests/globaledit.rs`. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. - **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dc68706f26..0104fe0a96 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -418,7 +418,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Actions panel: Delete Selection (bottom bar) deletes the selected set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.actionSet.delete`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: deleting a set asks first (a selected action is still deleted at once), Shift-click on a set's name selects a range of sets, ≡ › Delete does the same as the button, and Default Actions and the last set can be deleted. Undo does not restore a deleted set, and recorded commands cannot be selected or deleted | | Actions panel: ≡ › Duplicate copies the selected action or set ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.actionSet.duplicate`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected, and a copied set holds copies of all its actions. Undo does not remove the copy, and recorded commands cannot be duplicated | | Properties panel per context (every selection type's Quick Actions) | partial | one clip group: Edit Clipping Path and Edit Contents, at the top of the panel rather than in Quick Actions (G1); the overview's additional controls ([Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html)): Blend Options (one blend selected, with any tool, #4817), Repeat Options (one radial, grid or mirror repeat selected: Object › Repeat › Options, #5181; the overview does not list it) and Merge Live Paint (a Live Paint group with more) in Quick Actions, the Width Profile list (Width tool or a variable-width stroke) and an applied effect's Delete icon in Appearance, Corner and Corner Radius for corners selected with Direct Selection; Variable Font was already there; Edit Artboards: a Transform section (reference point, X, Y, W, H, preset, orientation) above the Artboards section (#5219) | -| Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); a group holding type or an image is not taken, as Global Edit does not take images or text; Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; the matches are highlighted before the start (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); rotated copies remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | +| Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); a group holding type or an image is not taken, as Global Edit does not take images or text; Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; the matches are highlighted before the start (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); rotated copies are matched and take an edit turned by their angle ([public source](https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html); `start_global_edit_takes_rotated_copies_and_an_edit_reaches_each_turned_by_its_angle`); reflected copies are not matched (VectorCraft's own rule) | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | | Stroke panel: custom arrowheads after the built-in ones in both arrowhead menus, from the arrowheads file that the panel menu's Edit Arrowheads… opens; saving the file reloads the menus, and a stroke keeps a copy of its custom head's art | done | [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) (Customize arrowheads); the file's layout (one symbol per head, pointing right, tip at the registration point, height drawn as wide as the built-in heads) is VectorCraft's own; `stroke.arrowheads.edit`, `stroke.arrowheads.list`, `stroke.arrowheads.reload`, `stroke.set` `{custom: name}`; engine `tests::arrowheads`, `stroke::tests_arrows::custom_heads_draw_their_own_art_at_the_tip` | From 951f689e8f14be259b61eb78c48cfeef40e243e8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 09:59:19 -0800 Subject: [PATCH 84/89] Export: an HP-GL plot of the art's bounds measures images by the art their effects add An image or a gradient mesh with a visible Crop Marks effect, or with a Vector Halftone effect that makes dots, is measured in a plot of the art's bounds. A halftone that makes no dots, such as one past the effect's cell limit, leaves the image out as before. --- crates/cad/src/hpgl/write.rs | 13 ++++++----- crates/cad/src/hpgl/write/tests.rs | 26 ++++++++++++++++++---- crates/engine/src/cmd/fileio/encode.rs | 4 ++-- crates/engine/src/cmd/fileio/tests_hpgl.rs | 23 +++++++++++++++++++ docs/mcp.md | 1 + 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 7d753e1801..95c8ecfb7a 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -1027,15 +1027,18 @@ fn overlaps(a: Rect, b: Rect) -> bool { /// Whether a plot draws nothing of `n` wherever it is, apart from what every export leaves out /// (hidden objects, guides and template layers): an object at 0% opacity, a non-printing layer, a /// placed image, a gradient mesh whose every point is at 0% opacity, or a path or compound path -/// without effects whose fills and strokes paint nothing. A clipping path still clips when the -/// plot draws nothing of it. -pub fn plots_nothing(n: &Node) -> bool { +/// without effects whose fills and strokes paint nothing. An image or a mesh in `doc` with a +/// visible Crop Marks effect, or with a Vector Halftone effect that makes dots, is not one of them. +/// A clipping path still clips when the plot draws nothing of it. +pub fn plots_nothing(doc: &Document, n: &Node) -> bool { let unpainted = || n.appearance.effects.is_empty() && top_stroke(n).is_none() && top_fill(n).is_none(); + let dots = || vectorcraft_effects::addons::halftone::halftone_art(doc, n).is_some_and(|g| g.children().is_some_and(|c| !c.is_empty())); + let adds_art = || vectorcraft_effects::has_crop_marks(n) || dots(); n.opacity <= 0.0 || match &n.kind { NodeKind::Layer { printable, .. } => !printable, - NodeKind::Image(_) => true, - NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity <= 0.0), + NodeKind::Image(_) => !adds_art(), + NodeKind::Mesh(m) => !adds_art() && m.points.iter().all(|p| p.opacity <= 0.0), NodeKind::Path { .. } | NodeKind::Compound { .. } => unpainted(), _ => false, } diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index c600a60eab..60f7cf5b3d 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -183,7 +183,7 @@ fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() { #[test] fn plots_nothing_picks_what_a_plot_draws_nothing_of() { type Make = fn(&mut Document) -> Node; - let cases: [(&str, Make, bool); 6] = [ + let cases: [(&str, Make, bool); 7] = [ ("stroked", |d| stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK), false), ("unpainted", |d| Node::path(d.alloc_id(), rect(10.0, 10.0, 40.0, 40.0), Appearance::basic(Paint::None, Paint::None, 1.0)), true), ( @@ -211,6 +211,24 @@ fn plots_nothing_picks_what_a_plot_draws_nothing_of() { }, true, ), + ( + "image with Crop Marks", + |d| { + let image = vectorcraft_doc::ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true }); + n + }, + false, + ), ("mesh", |d| Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), false), ( "clear mesh", @@ -230,16 +248,16 @@ fn plots_nothing_picks_what_a_plot_draws_nothing_of() { vec![n] }); let Some(n) = held else { panic!("{name}") }; - assert_eq!(plots_nothing(&n), nothing, "{name}"); + assert_eq!(plots_nothing(&d, &n), nothing, "{name}"); assert_eq!(text(&plot(&d, &opts(&d))) == "IN;PU;SP0;", nothing, "{name}"); } let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK)]); let layer = d.layers[0].id; - assert!(!plots_nothing(&d.layers[0])); + assert!(!plots_nothing(&d, &d.layers[0])); if let Some(NodeKind::Layer { printable, .. }) = d.node_mut(layer).map(|n| &mut n.kind) { *printable = false; } - assert!(plots_nothing(&d.layers[0])); + assert!(plots_nothing(&d, &d.layers[0])); assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 3461aca056..0bb18483cb 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -444,7 +444,7 @@ pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value, left_out: impl Fn(& /// when there is none, which such an export refuses. pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> { let doc = &*doc.without_edit_modes(); - bounds_without(doc, &|n| format == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(n)) + bounds_without(doc, &|n| format == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n)) } /// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden @@ -770,7 +770,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { { return Ok(enc); } - let left_out = |n: &Node| f.id == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(n); + let left_out = |n: &Node| f.id == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n); if use_artboards == Some(false) && let Some((art, q)) = art_page(C, doc, p, left_out)? { diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 5b7e3f0185..a7fe16690e 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -254,6 +254,29 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() { } } +/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left +/// out when it would take more cells than the effect makes. +#[test] +fn a_plot_of_the_arts_bounds_measures_images_by_their_halftone_dots() { + let mut s = two_boards(); + let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v)); + let printing = s.doc().unwrap().doc.layers[0].id; + s.execute("layer.new", &json!({})).unwrap(); + let mut png = vec![]; + image::DynamicImage::new_rgb8(100, 100).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "dots.png", "dataBase64": b64(&png), "at": [300, 300]})).unwrap(); + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + + // At 300 lines per inch a 100 pt image takes more cells than the effect makes. + s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 300}})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + + s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 20}})).unwrap(); + let out = String::from_utf8(plot(&mut s).unwrap()).unwrap(); + assert!(out.contains("PD"), "{out}"); +} + #[test] fn export_options_are_checked() { let mut s = two_boards(); diff --git a/docs/mcp.md b/docs/mcp.md index 5f9abe7988..19c0cba278 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1089,6 +1089,7 @@ way. `document.exportPdf {useArtboards: false}` writes that one page as well, an (unless `includeNonPrinting` or `createLayers` keeps them; a `.ai` file keeps layers by default). An HP-GL plot of the art's bounds is measured without what the plot leaves out: non-printing layers, objects at 0% opacity, placed images, gradient meshes whose every point is at 0% opacity, and paths without effects whose fills and strokes paint nothing. +An image or a mesh with a Crop Marks effect, or with a Vector Halftone effect that makes dots, is measured. Such objects in the art of a symbol that is not dynamic are left out of its instances too, and an instance whose symbol holds nothing else is left out. A document whose only visible art is left out is refused, and Export As refuses an HP-GL plot of it before the save From 7ff186bd315ec62a83b511ad7a1acf885a057b78 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 10:00:04 -0800 Subject: [PATCH 85/89] Type: a selected character's alternate glyphs show beside it on the canvas With a Type tool and exactly one character selected, the canvas shows up to five of that character's alternate glyphs in a row beside it. A click runs the command the Glyphs panel's double-click runs, as one undo step; an arrow at the end opens the Glyphs panel on Alternates for Current Selection when the character has more. Preferences > Type > Show Character Alternates (showCharacterAlternates, on by default) turns the row off. --- crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/prefs.rs | 4 + crates/engine/src/tests/prefs.rs | 20 ++ crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/alternates_bar.rs | 163 ++++++++++++ .../src/canvas/alternates_bar/tests.rs | 247 ++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/gu.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/panels/glyphs.rs | 123 +++++++++ crates/ui-egui/src/prefs_dialog/tests.rs | 23 ++ docs/mcp.md | 13 + docs/target-app-parity.md | 1 + 38 files changed, 682 insertions(+) create mode 100644 crates/ui-egui/src/canvas/alternates_bar.rs create mode 100644 crates/ui-egui/src/canvas/alternates_bar/tests.rs diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 7a948bfc87..b6c500749f 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -217,6 +217,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)), p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool), p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool), + p!("showCharacterAlternates", "Type", "Options", "Show Character Alternates", bool), p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool), p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool), p!("typedLists", "Type", "Options", "Start Lists From Typed Markers", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1e4629e448..86525e9214 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -111,6 +111,9 @@ pub struct Prefs { pub recent_fonts_count: u32, pub missing_glyph_protection: bool, pub highlight_alternate_glyphs: bool, + /// Type › Show Character Alternates: with a Type tool and one character selected, the canvas + /// shows that character's alternate glyphs beside it (on by default). + pub show_character_alternates: bool, /// Type › Highlight Substituted Fonts: on screen, text drawn in a substitute for a missing /// font is highlighted, in every document (on by default). pub highlight_substituted_fonts: bool, @@ -442,6 +445,7 @@ impl Default for Prefs { recent_fonts_count: 10, missing_glyph_protection: true, highlight_alternate_glyphs: false, + show_character_alternates: true, highlight_substituted_fonts: true, placeholder_text: true, typed_lists: true, diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index d88b8250a2..521d2eabde 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -1978,6 +1978,26 @@ fn highlight_alternate_glyphs_preference_is_off_by_default() { assert!(!s.prefs.highlight_alternate_glyphs); } +/// Preferences › Type › Show Character Alternates: on by default, listed in the Type category, +/// saved with the other preferences, and a value that is not true or false is refused. +#[test] +fn show_character_alternates_preference_is_on_by_default_and_saved() { + let mut s = Session::new(); + assert!(s.prefs.show_character_alternates, "on by default"); + let spec = crate::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == "showCharacterAlternates").expect("listed"); + assert_eq!((spec.category, spec.label), ("Type", "Show Character Alternates")); + assert_eq!(s.execute("prefs.get", &json!({"key": "showCharacterAlternates"})).unwrap(), json!(true)); + set_pref(&mut s, "showCharacterAlternates", json!(false)); + assert!(!s.prefs.show_character_alternates); + assert!(s.execute("prefs.set", &json!({"key": "showCharacterAlternates", "value": "yes"})).is_err(), "not a boolean"); + assert!(!s.prefs.show_character_alternates, "a refused value changes nothing"); + let saved: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap(); + assert!(!saved.show_character_alternates); + // Preferences saved before the key existed read it as on. + let old: Prefs = serde_json::from_value(json!({"highlightAlternateGlyphs": true})).unwrap(); + assert!(old.show_character_alternates); +} + /// Preferences › Type › Highlight Substituted Fonts: on by default, listed in the Type category, /// and saved with the other preferences. /// <https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html> diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 486c70de88..5c30f96246 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -15,6 +15,7 @@ use crate::stylus::StylusFeed; use crate::theme::{self, Tokens}; use crate::{CacheKey, VectorcraftApp, now_ms, widgets}; +mod alternates_bar; mod artboards; mod context; mod contour_bar; @@ -441,6 +442,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if app.ui.screen_mode < 3 { contour_bar::contour_bar(app, ui, &xf); + // After the task bar, which the row of character alternates keeps clear of. + alternates_bar::alternates_bar(app, ui, &xf); } if app.ui.screen_mode < 3 { crate::free_transform::show(app, ui, xf.rect); diff --git a/crates/ui-egui/src/canvas/alternates_bar.rs b/crates/ui-egui/src/canvas/alternates_bar.rs new file mode 100644 index 0000000000..83914ef11d --- /dev/null +++ b/crates/ui-egui/src/canvas/alternates_bar.rs @@ -0,0 +1,163 @@ +//! Character alternates on the canvas: with a Type tool and exactly one character selected, a +//! small row of that character's alternate glyphs shows beside it. A click on a glyph draws the +//! character with it, as a double-click on the glyph in the Glyphs panel's Show › Alternates for +//! Current Selection does, through the same command and so as one undo step. An arrow at the end +//! of the row opens the Glyphs panel when the character has more alternates than the row shows. +//! Preferences › Type › Show Character Alternates (`showCharacterAlternates`) turns the row off. +//! +//! Public behavior: selecting a character shows its alternate glyphs next to it, a click replaces +//! the character's glyph, at most five alternates show, and a control to their right opens the +//! Glyphs panel for the rest ("Replace a character with on-canvas alternate glyphs", +//! <https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=479>; +//! [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)). +//! The row's place, its size and the preference are VectorCraft's own. + +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_geom::Point; +use vectorcraft_text::FontDb; + +use super::{CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Tokens, Ui, Vec2, VectorcraftApp, Xf, pos2, vec2}; +use crate::panels::glyphs; + +/// The side of one glyph cell, in points. +const CELL: f32 = 28.0; +/// The width of the arrow that opens the Glyphs panel. +const MORE: f32 = 16.0; +/// The space between the row's edge and its cells: the frame's 1-point line and 3 points inside it. +const PAD: f32 = 4.0; +/// The space between the selected character and the row. +const GAP: f32 = 6.0; + +fn bar_id() -> egui::Id { + egui::Id::unique("character-alternates-bar") +} + +/// Where the row was last drawn (none while it isn't shown). +#[cfg(test)] +pub(super) fn bar_rect(ctx: &egui::Context) -> Option<egui::Rect> { + let id = bar_id(); + ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) +} + +/// The size of a row of `count` glyphs, with the arrow when `more`. +fn bar_size(count: usize, more: bool) -> Vec2 { + let cells = count as f32 * CELL + if more { MORE } else { 0.0 }; + vec2(cells + 2.0 * PAD, CELL + 2.0 * PAD) +} + +/// The document-space highlight quads of bytes `a..b` of text `id`, and whether the text is set +/// vertically. Kept until the document or the selection changes, so the text is laid out once for +/// each, not in every frame. Empty for a character the text does not draw (overset area type). +fn character_quads(app: &VectorcraftApp, ui: &Ui, id: NodeId, (a, b): (usize, usize)) -> Option<(Vec<[Point; 4]>, bool)> { + type Quads = (Vec<[Point; 4]>, bool); + let st = app.session.active()?; + let key = (st.uid, st.revision, id, a, b); + let slot = bar_id().with("quads"); + if let Some((cached_key, quads)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize, usize), Quads)>(slot)) + && cached_key == key + { + return Some(quads); + } + let NodeKind::Text(tx) = &st.doc.node(id)?.kind else { return None }; + let lay = vectorcraft_text::layout(FontDb::global(), tx); + let quads: Vec<[Point; 4]> = vectorcraft_text::selection_quads(&lay, a, b).into_iter().map(|q| q.map(|c| tx.xf * c)).collect(); + let found = (quads, tx.vertical); + ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone()))); + Some(found) +} + +/// The top-left corner of a row of `size` beside the selected character's screen box `sel`, inside +/// `area`. The row goes under a character of horizontal type, or over it when there is no room +/// under it; beside a character of vertical type, on its right, or on its left when there is no +/// room on the right. A side is also given up when the row would cover `avoid` there (the +/// Contextual Task Bar). With no room on either side the row takes the first one, moved into +/// `area`. +fn place(sel: egui::Rect, size: Vec2, area: egui::Rect, vertical: bool, avoid: Option<egui::Rect>) -> Pos2 { + let sides = if vertical { + [pos2(sel.right() + GAP, sel.center().y - size.y / 2.0), pos2(sel.left() - GAP - size.x, sel.center().y - size.y / 2.0)] + } else { + [pos2(sel.center().x - size.x / 2.0, sel.bottom() + GAP), pos2(sel.center().x - size.x / 2.0, sel.top() - GAP - size.y)] + }; + let last = (area.max - size).max(area.min); + // Along the character's line the row may slide to stay on the canvas; across it, it may not. + let slide = |at: Pos2| if vertical { pos2(at.x, at.y.clamp(area.min.y, last.y)) } else { pos2(at.x.clamp(area.min.x, last.x), at.y) }; + let fits = |at: Pos2| { + let r = egui::Rect::from_min_size(at, size); + area.contains_rect(r) && avoid.is_none_or(|bar| !bar.intersects(r)) + }; + let sides = sides.map(slide); + sides.into_iter().find(|at| fits(*at)).unwrap_or_else(|| sides[0].clamp(area.min, last)) +} + +/// The row of alternate glyphs beside the Type tool's one selected character. +pub(super) fn alternates_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { + // Not while a press or a drag is choosing the selection: the row would follow the pointer. + if !app.session.prefs.show_character_alternates || !app.session.tool_wants_text() || app.session.tool_busy() { + return; + } + let Some(found) = glyphs::on_canvas(app, ui) else { return }; + let Some((quads, vertical)) = character_quads(app, ui, found.id, found.bytes) else { return }; + let corners: Vec<Pos2> = quads.iter().flatten().map(|p| xf.to_screen(*p)).collect(); + if corners.is_empty() { + return; + } + let sel = egui::Rect::from_points(&corners); + // A character scrolled off the canvas has no row. + if !sel.intersects(xf.rect) { + return; + } + let t = Tokens::get(ui.ctx()); + let count = found.count(); + let at = place(sel, bar_size(count, found.more), xf.rect.shrink(4.0), vertical, super::task_bar_rect(ui.ctx())); + let px = (CELL * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; + let (mut pick, mut more) = (None, false); + egui::Area::new(bar_id()).order(egui::Order::Middle).fixed_pos(at).show(ui.ctx(), |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(4)) + .inner_margin(egui::Margin::same(PAD as i8 - 1)) + .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: egui::Color32::from_black_alpha(70) }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + for i in 0..count { + let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 2, t.hover); + } + ui.painter().rect_stroke(rect.shrink(0.5), 0, Stroke::new(0.5, t.border), StrokeKind::Inside); + if let Some(tex) = found.texture(ui.ctx(), i, px) { + let uv = egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + ui.painter().image(tex.id(), rect, uv, t.text_strong); + } + let tip = found.tip(i); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); + if resp.on_hover_text(&tip).clicked() { + pick = Some(i); + } + } + if found.more { + let (rect, resp) = ui.allocate_exact_size(vec2(MORE, CELL), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 2, t.hover); + } + let c = rect.center(); + let arrow = vec![pos2(c.x - 2.5, c.y - 5.0), pos2(c.x + 3.5, c.y), pos2(c.x - 2.5, c.y + 5.0)]; + ui.painter().add(Shape::convex_polygon(arrow, t.text_strong, Stroke::NONE)); + let tip = tl!("Show all alternates in the Glyphs panel"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + more = resp.on_hover_text(tip).clicked(); + } + }); + }); + }); + if let Some(i) = pick { + found.pick(app, i); + } else if more { + glyphs::show_alternates(app, ui.ctx()); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/canvas/alternates_bar/tests.rs b/crates/ui-egui/src/canvas/alternates_bar/tests.rs new file mode 100644 index 0000000000..be2eb59f97 --- /dev/null +++ b/crates/ui-egui/src/canvas/alternates_bar/tests.rs @@ -0,0 +1,247 @@ +use egui::{Pos2, Rect}; +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_text::FontDb; + +use super::*; + +/// The Type tool editing `text` in a new document. +fn editing(text: &str, vertical: bool) -> (VectorcraftApp, Value) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 120, "y": 80, "text": text, "vertical": vertical})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + (app, id) +} + +fn select(app: &mut VectorcraftApp, start: usize, end: usize) { + app.session.set_tool_option("select", &json!({"start": start, "end": end})); +} + +const SCREEN: Rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 600.0)); + +/// One frame of the canvas with `events`; where the row of alternates is afterwards, if it shows. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<Rect> { + egui_extras::install_image_loaders(ctx); + let raw = egui::RawInput { screen_rect: Some(SCREEN), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| super::super::show(app, ui)); + out.textures_delta.clear(); + bar_rect(ctx) +} + +/// The glyphs the row offers and whether it has the arrow, as the canvas finds them. +fn offered(app: &mut VectorcraftApp, ctx: &egui::Context) -> Option<(Vec<u32>, bool)> { + let mut found = None; + let raw = egui::RawInput { screen_rect: Some(SCREEN), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + found = glyphs::on_canvas(app, ui).map(|f| ((0..f.count()).filter_map(|i| f.gid(i)).collect(), f.more)); + }); + out.textures_delta.clear(); + found +} + +/// A click at `at`, with a frame before it that lays the row out and moves the pointer there. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, ctx, vec![button(true), button(false)]); +} + +/// The center of cell `i` of the row at `bar`. +fn cell(bar: Rect, i: usize) -> Pos2 { + bar.min + vec2(PAD + CELL * (i as f32 + 0.5), PAD + CELL / 2.0) +} + +/// The center of the arrow of a row of `count` cells at `bar`. +fn arrow(bar: Rect, count: usize) -> Pos2 { + bar.min + vec2(PAD + CELL * count as f32 + MORE / 2.0, PAD + CELL / 2.0) +} + +fn glyph_ids(app: &VectorcraftApp, id: &Value) -> Vec<u32> { + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + let mut glyphs = vectorcraft_text::layout(FontDb::global(), text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs.iter().map(|g| g.gid).collect() +} + +fn undo_steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// The screen box of bytes `a..b` of the text, as the canvas of [`frame`] draws it. +fn character_box(app: &VectorcraftApp, id: &Value, a: usize, b: usize) -> Rect { + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + let lay = vectorcraft_text::layout(FontDb::global(), text); + let xf = app.canvas_xf().expect("a frame was drawn"); + let corners: Vec<Pos2> = vectorcraft_text::selection_quads(&lay, a, b).iter().flatten().map(|p| xf.to_screen(text.xf * *p)).collect(); + Rect::from_points(&corners) +} + +/// With the Type tool and exactly one character selected, the canvas shows up to five of that +/// character's alternates beside it, without the glyph it is drawn with, and an arrow when it has +/// more. A click draws the character with the glyph as one undo step and keeps the text. A caret, +/// a longer selection, a character without alternates, another tool and the preference turned off +/// each show no row. +#[test] +fn one_selected_character_shows_its_alternates_and_a_click_replaces_its_glyph() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let (mut app, id) = editing("xay", false); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + let ctx = egui::Context::default(); + let drawn = glyph_ids(&app, &id); + + select(&mut app, 1, 1); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "a caret selects no character"); + select(&mut app, 0, 2); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "two characters are selected"); + select(&mut app, 0, 1); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "x has no alternates"); + + select(&mut app, 1, 2); + let bar = frame(&mut app, &ctx, vec![]).expect("the a has alternates"); + let task_bar = super::super::task_bar_rect(&ctx).expect("the task bar shows for type being edited"); + let (gids, more) = offered(&mut app, &ctx).expect("offered"); + assert_eq!(gids.len(), glyphs::ON_CANVAS_ALTERNATES, "five of the six alternates"); + assert!(more, "the sixth is in the Glyphs panel"); + assert_eq!(gids[0], u1); + assert!(!gids.contains(&drawn[1]), "the glyph the a is drawn with is not offered"); + assert_eq!(bar.size(), bar_size(5, true), "five cells and the arrow"); + // The Contextual Task Bar is under the type, so the row goes over the character. + // The row is placed on whole pixels, so it is within one of where the character's box puts it. + let sel = character_box(&app, &id, 1, 2); + assert!(task_bar.intersects(Rect::from_min_size(pos2(bar.left(), sel.bottom() + GAP), bar.size())), "{task_bar:?} {sel:?}"); + assert!((sel.top() - bar.bottom() - GAP).abs() < 1.0, "over the character: {bar:?} {sel:?}"); + assert!((bar.center().x - sel.center().x).abs() < 1.0, "centered on it"); + // Without the task bar the row is under the character. + app.ui.task_bar = false; + frame(&mut app, &ctx, vec![]); + let under = frame(&mut app, &ctx, vec![]).expect("the row"); + assert!((under.top() - sel.bottom() - GAP).abs() < 1.0, "under the character: {under:?} {sel:?}"); + app.ui.task_bar = true; + frame(&mut app, &ctx, vec![]); + assert_eq!(frame(&mut app, &ctx, vec![]), Some(bar)); + + // A click on the first glyph. + let undo = undo_steps(&app); + click(&mut app, &ctx, cell(bar, 0)); + assert_eq!(undo_steps(&app), undo + 1, "one undo step; status: {}", app.ui.status); + assert_eq!(glyph_ids(&app, &id), [drawn[0], u1, drawn[2]], "the a is drawn with the glyph"); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "xay", "the text keeps its characters"); + + // The row stays for the same character: its default glyph first, the drawn glyph left out. + let bar = frame(&mut app, &ctx, vec![]).expect("still one character selected"); + let (gids, _) = offered(&mut app, &ctx).expect("offered"); + assert_eq!(gids[0], drawn[1], "the default glyph comes first"); + assert!(!gids.contains(&u1)); + click(&mut app, &ctx, cell(bar, 0)); + assert_eq!(undo_steps(&app), undo + 2); + assert_eq!(glyph_ids(&app, &id), drawn, "the default glyph again"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [drawn[0], u1, drawn[2]], "undo takes one pick back"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), drawn); + + // Preferences › Type › Show Character Alternates. + select(&mut app, 1, 2); + assert!(frame(&mut app, &ctx, vec![]).is_some()); + app.run("prefs.set", json!({"key": "showCharacterAlternates", "value": false})).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "the preference is off"); + app.run("prefs.set", json!({"key": "showCharacterAlternates", "value": true})).unwrap(); + assert!(frame(&mut app, &ctx, vec![]).is_some()); + + app.session.select_tool("select", Default::default()).unwrap(); + assert_eq!(frame(&mut app, &ctx, vec![]), None, "the Selection tool edits no text"); +} + +/// The arrow at the end of the row opens the Glyphs panel on Show › Alternates for Current +/// Selection and changes nothing in the document. +#[test] +fn the_arrow_opens_the_glyphs_panel_on_the_alternates() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let (mut app, id) = editing("xay", false); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 1, 2); + let ctx = egui::Context::default(); + app.run("window.panel.close", json!({"panel": "glyphs"})).ok(); + assert!(!crate::panel_docking::visible(&app.ui, "glyphs")); + let bar = frame(&mut app, &ctx, vec![]).expect("the row"); + let (undo, drawn) = (undo_steps(&app), glyph_ids(&app, &id)); + click(&mut app, &ctx, arrow(bar, 5)); + assert!(crate::panel_docking::visible(&app.ui, "glyphs"), "status: {}", app.ui.status); + // The Show menu's entry after its seven character subsets. + assert_eq!(crate::panels::pstate::<usize>(&ctx, "gl-subset"), 7); + assert_eq!(undo_steps(&app), undo); + assert_eq!(glyph_ids(&app, &id), drawn); +} + +/// A medial letter of a joined Arabic word gets the alternates of its medial form, and a click +/// applies the feature that draws one to that letter alone: its neighbors keep their joining +/// forms. Three alternates need no arrow. +#[test] +fn a_letter_of_a_joined_word_keeps_the_word_joined() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let g = |c| u32::from(source_sans_gid(c).expect("a glyph")); + let (mut app, id) = editing("\u{628}\u{62A}\u{646}", false); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + select(&mut app, 2, 4); + let ctx = egui::Context::default(); + let bar = frame(&mut app, &ctx, vec![]).expect("the medial teh has alternates"); + let (gids, more) = offered(&mut app, &ctx).expect("offered"); + assert_eq!((gids.len(), more), (3, false), "the medial form's three alternates: {gids:?}"); + assert_eq!(bar.size(), bar_size(3, false), "three cells and no arrow"); + let at = gids.iter().position(|gid| *gid == g('x')).expect("the alternate salt=2 draws"); + let undo = undo_steps(&app); + click(&mut app, &ctx, cell(bar, at)); + assert_eq!(undo_steps(&app), undo + 1, "status: {}", app.ui.status); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')], "the neighbors keep their initial and final forms"); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "\u{628}\u{62A}\u{646}"); + assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "a feature draws it, not a glyph choice"); +} + +/// The row stands on the right of a character of vertical type, level with it. +#[test] +fn the_row_stands_beside_a_character_of_vertical_type() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let (mut app, id) = editing("xay", true); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 1, 2); + let ctx = egui::Context::default(); + app.ui.task_bar = false; + // The first frame fits the view to the canvas. + frame(&mut app, &ctx, vec![]); + let bar = frame(&mut app, &ctx, vec![]).expect("the row"); + let sel = character_box(&app, &id, 1, 2); + assert!((bar.left() - sel.right() - GAP).abs() < 1.0, "on the right of the character: {bar:?} {sel:?}"); + assert!((bar.center().y - sel.center().y).abs() < 1.0, "level with it"); +} + +/// [`place`]: under the character, over it when the canvas ends below or the task bar is there, +/// on the right or left of vertical type, and inside the canvas when neither side has room. +#[test] +fn the_row_is_placed_on_the_side_that_has_room() { + let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0)); + let size = vec2(100.0, 34.0); + let sel = Rect::from_min_max(pos2(190.0, 100.0), pos2(210.0, 130.0)); + assert_eq!(place(sel, size, area, false, None), pos2(150.0, 136.0), "under"); + let low = sel.translate(vec2(0.0, 150.0)); + assert_eq!(place(low, size, area, false, None), pos2(150.0, 210.0), "over: no room under"); + let task_bar = Rect::from_min_max(pos2(100.0, 140.0), pos2(300.0, 180.0)); + assert_eq!(place(sel, size, area, false, Some(task_bar)), pos2(150.0, 60.0), "over: the task bar is under"); + let edge = sel.translate(vec2(-185.0, 0.0)); + assert_eq!(place(edge, size, area, false, None), pos2(0.0, 136.0), "moved along the line to stay on the canvas"); + assert_eq!(place(sel, size, area, true, None), pos2(216.0, 98.0), "vertical type: on the right"); + let right = sel.translate(vec2(150.0, 0.0)); + assert_eq!(place(right, size, area, true, None), pos2(234.0, 98.0), "vertical type: on the left"); + let small = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 60.0)); + let inside = Rect::from_min_max(pos2(190.0, 20.0), pos2(210.0, 40.0)); + assert_eq!(place(inside, size, small, false, None), pos2(150.0, 26.0), "no room on either side: kept inside the canvas"); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28944173dd..28e1f4b523 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -889,6 +889,9 @@ font-technology Variable Променливи The selected character's font is not available. Шрифтът на избрания знак не е наличен. No alternates for the selected character. Няма алтернативи за избрания знак. Double-click to replace Двойно щракване за замяна + Click to replace Щракване за замяна + Show all alternates in the Glyphs panel Показване на всички алтернативи в панела „Глифи“ + Show Character Alternates Показване на алтернативите на знака Select one character with the Type tool first. Първо изберете един знак с инструмента „Текст“. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} е глиф на {font}, а не на шрифта, с който е зададен избраният текст ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} принадлежи на {base} (U+{base_code}) {place}; избраният от вас знак е {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..029e300063 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1701,6 +1701,9 @@ digits Malayalam Malajalam The selected character's font is not available. Font odabranog znaka nije dostupan. No alternates for the selected character. Nema alternativa za odabrani znak. Double-click to replace Dvostruki klik za zamjenu + Click to replace Klik za zamjenu + Show all alternates in the Glyphs panel Prikaži sve alternative u panelu Glifovi + Show Character Alternates Prikaži alternative znaka Select one character with the Type tool first. Prvo odaberite jedan znak alatom za tekst. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glif fonta {font}, a ne fonta kojim je postavljen odabrani tekst ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pripada znaku {base} (U+{base_code}) {place}; vaš odabir je {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..f6fcdceae9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1307,6 +1307,9 @@ antiAlias Sharp Nítid The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible. No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat. Double-click to replace Feu doble clic per substituir + Click to replace Feu clic per substituir + Show all alternates in the Glyphs panel Mostra totes les alternatives al tauler Glifs + Show Character Alternates Mostra les alternatives del caràcter Select one character with the Type tool first. Selecciona primer un caràcter amb l’eina de text. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} és un glif de {font}, no del tipus de lletra amb què està escrit el text seleccionat ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertany a {base} (U+{base_code}) {place}; la teva selecció és {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..7c5679c6e5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1730,6 +1730,9 @@ digits Malayalam Malajálamské The selected character's font is not available. Písmo vybraného znaku není k dispozici. No alternates for the selected character. Vybraný znak nemá žádné alternativy. Double-click to replace Dvojklikem nahradíte + Click to replace Kliknutím nahradíte + Show all alternates in the Glyphs panel Zobrazit všechny alternativy v panelu Glyfy + Show Character Alternates Zobrazovat alternativy znaku Select one character with the Type tool first. Nejprve nástrojem Text vyberte jeden znak. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glyf písma {font}, ne písma, kterým je vybraný text nastaven ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} patří znaku {base} (U+{base_code}) {place}; váš výběr je {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b3de0ae89a..043fb6d76e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1636,6 +1636,9 @@ digits Malayalam Malayalam The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig. No alternates for the selected character. Ingen alternativer til det markerede tegn. Double-click to replace Dobbeltklik for at erstatte + Click to replace Klik for at erstatte + Show all alternates in the Glyphs panel Vis alle alternativer i Glyffer-panelet + Show Character Alternates Vis tegnalternativer Select one character with the Type tool first. Markér først ét tegn med tekstværktøjet. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} er et glyf i {font}, ikke i den skrifttype, den markerede tekst er sat i ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hører til {base} (U+{base_code}) {place}; din markering er {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..6db55d3b8d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -891,6 +891,9 @@ font-technology Variable Variabel The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar. No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen. Double-click to replace Doppelklicken zum Ersetzen + Click to replace Klicken zum Ersetzen + Show all alternates in the Glyphs panel Alle Alternativen im Bedienfeld „Glyphen“ anzeigen + Show Character Alternates Zeichenalternativen anzeigen Select one character with the Type tool first. Wählen Sie zuerst mit dem Text-Werkzeug ein Zeichen aus. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} ist eine Glyphe von {font}, nicht der Schrift, in der der ausgewählte Text gesetzt ist ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} gehört zu {base} (U+{base_code}) {place}; Ihre Auswahl ist {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..0c7e12c47c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -862,6 +862,9 @@ font-technology Variable Μεταβλητές The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη. No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα. Double-click to replace Διπλό κλικ για αντικατάσταση + Click to replace Κλικ για αντικατάσταση + Show all alternates in the Glyphs panel Εμφάνιση όλων των εναλλακτικών στον πίνακα Γλύφοι + Show Character Alternates Εμφάνιση εναλλακτικών χαρακτήρα Select one character with the Type tool first. Επιλέξτε πρώτα έναν χαρακτήρα με το εργαλείο κειμένου. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). Ο γλύφος {glyph} ανήκει στη γραμματοσειρά {font}, όχι σε αυτήν του επιλεγμένου κειμένου ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). Ο γλύφος {glyph} ανήκει στο {base} (U+{base_code}) {place}· η επιλογή σας είναι {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..6bf2cd4ed3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -904,6 +904,9 @@ font-technology Variable Variables The selected character's font is not available. La fuente del carácter seleccionado no está disponible. No alternates for the selected character. No hay alternativas para el carácter seleccionado. Double-click to replace Doble clic para reemplazar + Click to replace Clic para reemplazar + Show all alternates in the Glyphs panel Mostrar todas las alternativas en el panel Glifos + Show Character Alternates Mostrar alternativas de carácter Select one character with the Type tool first. Primero selecciona un carácter con la herramienta Texto. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} es un glifo de {font}, no de la fuente del texto seleccionado ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertenece a {base} (U+{base_code}) {place}; tu selección es {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..6d360f5648 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1703,6 +1703,9 @@ digits Malayalam Malajalam The selected character's font is not available. Valitun merkin fontti ei ole saatavilla. No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja. Double-click to replace Korvaa kaksoisnapsauttamalla + Click to replace Korvaa napsauttamalla + Show all alternates in the Glyphs panel Näytä kaikki vaihtoehdot Merkit-paneelissa + Show Character Alternates Näytä merkin vaihtoehdot Select one character with the Type tool first. Valitse ensin yksi merkki tekstityökalulla. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} on fontin {font} glyfi, ei valitun tekstin fontin ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} kuuluu merkille {base} (U+{base_code}) {place}; valintasi on {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..f0f50e5a17 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -907,6 +907,9 @@ font-technology Variable Variables The selected character's font is not available. La police du caractère sélectionné n’est pas disponible. No alternates for the selected character. Aucune variante pour le caractère sélectionné. Double-click to replace Double-cliquez pour remplacer + Click to replace Cliquez pour remplacer + Show all alternates in the Glyphs panel Afficher toutes les variantes dans le panneau Glyphes + Show Character Alternates Afficher les variantes de caractère Select one character with the Type tool first. Sélectionnez d’abord un caractère avec l’outil Texte. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} est un glyphe de {font}, et non de la police du texte sélectionné ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartient à {base} (U+{base_code}) {place} ; votre sélection est {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..ab1ae3d432 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -1046,6 +1046,9 @@ Double-click to insert દાખલ કરવા ડબલ-ક્લિક કરો Double-click to rename નામ બદલવા ડબલ-ક્લિક કરો Double-click to replace બદલવા ડબલ-ક્લિક કરો + Click to replace બદલવા ક્લિક કરો + Show all alternates in the Glyphs panel ગ્લિફ્સ પેનલમાં બધા વૈકલ્પિક બતાવો + Show Character Alternates અક્ષરના વૈકલ્પિક બતાવો Double-click to target the contents સામગ્રી લક્ષ્ય બનાવવા ડબલ-ક્લિક કરો Download: ડાઉનલોડ: Drag ખેંચો diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..c11da70211 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1463,6 +1463,9 @@ antiAlias Sharp शार्प Double-click to insert सम्मिलित करने के लिए डबल क्लिक करें Double-click to rename नाम बदलने के लिए डबल क्लिक करें Double-click to replace प्रतिस्थापित करने के लिए डबल क्लिक करें + Click to replace प्रतिस्थापित करने के लिए क्लिक करें + Show all alternates in the Glyphs panel ग्लिफ़्स पैनल में सभी ऑप्शन दिखाएँ + Show Character Alternates कैरेक्टर के ऑप्शन दिखाएँ Select one character with the Type tool first. पहले टाइप टूल से एक कैरेक्टर सिलेक्ट करें। {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} {font} का ग्लिफ़ है, उस फ़ॉन्ट का नहीं जिसमें सिलेक्ट किया टेक्स्ट सेट है ({text_font})। {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} {base} (U+{base_code}) का है, {place}; आपका सिलेक्शन {sel} (U+{code}) है। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7f2f03afb2..74f921a6e3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1172,6 +1172,9 @@ font-technology Variable Változó The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el. No alternates for the selected character. A kijelölt karakternek nincsenek változatai. Double-click to replace Csere dupla kattintással + Click to replace Csere kattintással + Show all alternates in the Glyphs panel Minden változat megjelenítése a Glifák panelen + Show Character Alternates Karakterváltozatok megjelenítése Select one character with the Type tool first. Előbb jelölj ki egy karaktert a Szöveg eszközzel. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). A(z) {glyph} a(z) {font} glifája, nem a kijelölt szöveg betűtípusáé ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). A(z) {glyph} ehhez tartozik: {base} (U+{base_code}) {place}; a te kijelölésed: {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c7c6b956db..8abbc41b00 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1636,6 +1636,9 @@ digits Malayalam Malayalam The selected character's font is not available. Font karakter yang dipilih tidak tersedia. No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih. Double-click to replace Klik ganda untuk mengganti + Click to replace Klik untuk mengganti + Show all alternates in the Glyphs panel Tampilkan semua alternatif di panel Glif + Show Character Alternates Tampilkan alternatif karakter Select one character with the Type tool first. Pilih satu karakter dengan alat teks terlebih dahulu. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} adalah glif {font}, bukan dari font yang dipakai teks terpilih ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} milik {base} (U+{base_code}) {place}; pilihan Anda adalah {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f832a5baf3..e6d3f2a470 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -912,6 +912,9 @@ font-technology Variable Variabili The selected character's font is not available. Il font del carattere selezionato non è disponibile. No alternates for the selected character. Nessuna alternativa per il carattere selezionato. Double-click to replace Fai doppio clic per sostituire + Click to replace Fai clic per sostituire + Show all alternates in the Glyphs panel Mostra tutte le alternative nel pannello Glifi + Show Character Alternates Mostra alternative carattere Select one character with the Type tool first. Seleziona prima un carattere con lo strumento Testo. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} è un glifo di {font}, non del font del testo selezionato ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartiene a {base} (U+{base_code}) {place}; la tua selezione è {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3abb388f49..ec5b26e743 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1557,6 +1557,9 @@ antiAlias Sharp シャープ The selected character's font is not available. 選択した文字のフォントを使用できません。 No alternates for the selected character. 選択した文字には異体字がありません。 Double-click to replace ダブルクリックで置換 + Click to replace クリックで置換 + Show all alternates in the Glyphs panel 字形パネルですべての異体字を表示 + Show Character Alternates 文字の異体字を表示 Select one character with the Type tool first. 先に文字ツールで文字を 1 つ選択してください。 {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} は {font} のグリフで、選択したテキストのフォント ({text_font}) のものではありません。 {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} は {base} (U+{base_code}) の{place}のグリフです。選択中の文字は {sel} (U+{code}) です。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..54e61cf8e1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1574,6 +1574,9 @@ digits Malayalam მალაიალამური The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია. No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს. Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად + Click to replace დაწკაპეთ ჩასანაცვლებლად + Show all alternates in the Glyphs panel ყველა ალტერნატივის ჩვენება გლიფების პანელში + Show Character Alternates სიმბოლოს ალტერნატივების ჩვენება Select one character with the Type tool first. ჯერ ტექსტის ხელსაწყოთი მონიშნეთ ერთი სიმბოლო. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} არის {font}-ის გლიფი და არა იმ შრიფტისა, რომლითაც მონიშნული ტექსტია დაწყობილი ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} ეკუთვნის სიმბოლოს {base} (U+{base_code}) {place}; თქვენი მონიშნულია {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..57d1e4d13a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1547,6 +1547,9 @@ antiAlias Sharp 날카롭게 The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다. No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다. Double-click to replace 더블클릭하여 바꾸기 + Click to replace 클릭하여 바꾸기 + Show all alternates in the Glyphs panel 글리프 패널에서 모든 대체 글리프 표시 + Show Character Alternates 문자의 대체 글리프 표시 Select one character with the Type tool first. 먼저 문자 도구로 문자 하나를 선택하세요. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}은(는) {font}의 글리프이며, 선택한 텍스트의 글꼴({text_font})의 것이 아닙니다. {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}은(는) {base}(U+{base_code})의 {place} 글리프입니다. 선택한 문자는 {sel}(U+{code})입니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..1237335c94 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2154,6 +2154,9 @@ digits Malayalam Malayalam The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar. No alternates for the selected character. Geen alternatieven voor het geselecteerde teken. Double-click to replace Dubbelklik om te vervangen + Click to replace Klik om te vervangen + Show all alternates in the Glyphs panel Alle alternatieven tonen in het paneel Glyphs + Show Character Alternates Tekenalternatieven tonen Select one character with the Type tool first. Selecteer eerst één teken met het Type-gereedschap. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} is een glyph van {font}, niet van het lettertype van de geselecteerde tekst ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hoort bij {base} (U+{base_code}) {place}; uw selectie is {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..cadd8cadf3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1642,6 +1642,9 @@ digits Malayalam Malajalam The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna. No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku. Double-click to replace Kliknij dwukrotnie, aby zastąpić + Click to replace Kliknij, aby zastąpić + Show all alternates in the Glyphs panel Pokaż wszystkie alternatywy w panelu Glify + Show Character Alternates Pokaż alternatywy znaku Select one character with the Type tool first. Najpierw zaznacz jeden znak narzędziem Tekst. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} to glif czcionki {font}, a nie czcionki zaznaczonego tekstu ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} należy do znaku {base} (U+{base_code}) {place}; Twoje zaznaczenie to {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..b0ca39a463 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -814,6 +814,9 @@ antiAlias Sharp Nítido The selected character's font is not available. A fonte do caractere selecionado não está disponível. No alternates for the selected character. Nenhuma alternativa para o caractere selecionado. Double-click to replace Clique duplo para substituir + Click to replace Clique para substituir + Show all alternates in the Glyphs panel Mostrar todas as alternativas no painel Glifos + Show Character Alternates Mostrar Alternativas de Caractere Select one character with the Type tool first. Primeiro selecione um caractere com a ferramenta Texto. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} é um glifo de {font}, não da fonte do texto selecionado ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertence a {base} (U+{base_code}) {place}; sua seleção é {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..cea217fb09 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -902,6 +902,9 @@ font-technology Variable Вариативные The selected character's font is not available. Шрифт выделенного символа недоступен. No alternates for the selected character. У выделенного символа нет альтернатив. Double-click to replace Дважды щёлкните для замены + Click to replace Щёлкните для замены + Show all alternates in the Glyphs panel Показать все альтернативы в панели «Глифы» + Show Character Alternates Показывать альтернативы символа Select one character with the Type tool first. Сначала выделите один символ инструментом «Текст». {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — глиф шрифта {font}, а не шрифта выделенного текста ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} относится к символу {base} (U+{base_code}) {place}; выделен символ {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..5b8a646ee7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -891,6 +891,9 @@ font-technology Variable Variabla The selected character's font is not available. Typsnittet för det markerade tecknet är inte tillgängligt. No alternates for the selected character. Inga alternativ för det markerade tecknet. Double-click to replace Dubbelklicka för att ersätta + Click to replace Klicka för att ersätta + Show all alternates in the Glyphs panel Visa alla alternativ i panelen Glyfer + Show Character Alternates Visa teckenalternativ Select one character with the Type tool first. Markera först ett tecken med textverktyget. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} är en glyf i {font}, inte i det typsnitt den markerade texten är satt i ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hör till {base} (U+{base_code}) {place}; din markering är {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..1e6befc6ac 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -883,6 +883,9 @@ font-technology Variable Değişken The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor. No alternates for the selected character. Seçili karakter için alternatif yok. Double-click to replace Değiştirmek için çift tıklayın + Click to replace Değiştirmek için tıklayın + Show all alternates in the Glyphs panel Tüm alternatifleri Glifler panelinde göster + Show Character Alternates Karakter Alternatiflerini Göster Select one character with the Type tool first. Önce Yazım aracıyla bir karakter seçin. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}, seçili metnin yazı tipine ({text_font}) değil, {font} yazı tipine ait bir glif. {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}, {base} (U+{base_code}) karakterine aittir ({place}); seçiminiz {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..a9c466ea7b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -906,6 +906,9 @@ font-technology Variable Варіативні The selected character's font is not available. Шрифт виділеного символу недоступний. No alternates for the selected character. Виділений символ не має альтернатив. Double-click to replace Двічі клацніть для заміни + Click to replace Клацніть для заміни + Show all alternates in the Glyphs panel Показати всі альтернативи на панелі «Гліфи» + Show Character Alternates Показувати альтернативи символу Select one character with the Type tool first. Спершу виділіть один символ інструментом «Текст». {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — гліф шрифту {font}, а не шрифту виділеного тексту ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} належить символу {base} (U+{base_code}) {place}; виділено символ {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..e1b59d706b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1065,6 +1065,9 @@ font-technology Variable Biến thiên The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng. No alternates for the selected character. Ký tự đã chọn không có glyph thay thế. Double-click to replace Bấm đúp để thay thế + Click to replace Bấm để thay thế + Show all alternates in the Glyphs panel Hiện tất cả glyph thay thế trong bảng Glyph + Show Character Alternates Hiện glyph thay thế của ký tự Select one character with the Type tool first. Trước hết hãy chọn một ký tự bằng công cụ Chữ. {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} là glyph của {font}, không phải của phông chữ mà văn bản đã chọn đang dùng ({text_font}). {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} thuộc về {base} (U+{base_code}) {place}; ký tự bạn chọn là {sel} (U+{code}). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..81564a224b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -846,6 +846,9 @@ antiAlias Sharp 锐利 The selected character's font is not available. 所选字符的字体不可用。 No alternates for the selected character. 所选字符没有替代字。 Double-click to replace 双击以替换 + Click to replace 单击以替换 + Show all alternates in the Glyphs panel 在“字形”面板中显示所有替代字 + Show Character Alternates 显示字符的替代字 Select one character with the Type tool first. 请先用文字工具选择一个字符。 {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所选文本所用字体({text_font})的字形。 {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 属于 {base}(U+{base_code}){place};您选择的是 {sel}(U+{code})。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..696af378d7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -875,6 +875,9 @@ font-technology Variable 可變 The selected character's font is not available. 選取字元的字體無法使用。 No alternates for the selected character. 選取的字元沒有替代字。 Double-click to replace 按兩下以取代 + Click to replace 按一下以取代 + Show all alternates in the Glyphs panel 在「字符」面板中顯示所有替代字 + Show Character Alternates 顯示字元的替代字 Select one character with the Type tool first. 請先用文字工具選取一個字元。 {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所選文字所用字體({text_font})的字形。 {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 屬於 {base}(U+{base_code}){place};您選取的是 {sel}(U+{code})。 diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 7c0a18d14f..2a3251b012 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -564,6 +564,129 @@ fn apply_alternate(app: &mut VectorcraftApp, id: NodeId, bytes: (usize, usize), } } +/// The most alternate glyphs the canvas shows beside a selected character. Public behavior: at +/// most five alternates show on the canvas, and an icon to their right opens the Glyphs panel when +/// the font has more ("Replace a character with on-canvas alternate glyphs", +/// <https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=479>). +pub(crate) const ON_CANVAS_ALTERNATES: usize = 5; + +/// The alternate glyphs the canvas offers beside the Type tool's one selected character +/// ([`on_canvas`]). +pub(crate) struct OnCanvas { + /// The text the character is in. + pub id: NodeId, + /// The character's bytes in the text, start and end. + pub bytes: (usize, usize), + /// The face that draws the character's glyph. + face: std::sync::Arc<vectorcraft_text::FontFace>, + /// Up to [`ON_CANVAS_ALTERNATES`] glyphs, none of them the glyph the character is drawn with. + cells: Vec<Variant>, + /// Whether the character has alternates that [`Self::cells`] leaves out. + pub more: bool, + features: Vec<String>, + has_choice: bool, +} + +impl OnCanvas { + /// How many glyphs are offered: 1 to [`ON_CANVAS_ALTERNATES`]. + pub fn count(&self) -> usize { + self.cells.len() + } + + /// The id of glyph `i`. + #[cfg(test)] + pub fn gid(&self, i: usize) -> Option<u32> { + self.cells.get(i).map(|c| c.gid) + } + + /// The preview of glyph `i` in a square of `px` pixels. + pub fn texture(&self, ctx: &egui::Context, i: usize, px: u32) -> Option<egui::TextureHandle> { + self.cells.get(i).map(|c| glyph_texture(ctx, &self.face, c.gid, px)) + } + + /// The tooltip of glyph `i`: its id, the feature that draws it, and what a click does. + pub fn tip(&self, i: usize) -> String { + let Some(c) = self.cells.get(i) else { return String::new() }; + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &c.gid.to_string())]); + let glyph = match (&c.apply, c.default) { + (_, true) => glyph, + (Some(tag), _) => format!("{glyph} ({tag})"), + (None, _) => format!("{glyph} ({})", String::from_utf8_lossy(&c.feature)), + }; + let mut tip = format!("{glyph}\n{}", tl!("Click to replace")); + if !c.default && c.apply.is_none() { + tip.push('\n'); + tip.push_str(tl!("No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors.")); + } + tip + } + + /// Draw the character with glyph `i`, as a double-click on that glyph in Show › Alternates for + /// Current Selection does: one command, so one undo step. + pub fn pick(&self, app: &mut VectorcraftApp, i: usize) { + let Some(c) = self.cells.get(i) else { return }; + let pick = match (&c.apply, c.default) { + (_, true) => Pick::Default, + (Some(tag), _) => Pick::Feature(tag.clone()), + (None, _) => Pick::Glyph(c.gid), + }; + super::character::end_typing(app); + apply_alternate(app, self.id, self.bytes, &self.features, self.has_choice, pick); + } +} + +/// What the canvas offers beside the Type tool's selection: the alternates of the selected +/// character as it stands in its word ([`alternates_found`], found once for each document +/// revision), in the font's order and without the glyph the character is drawn with. A character +/// drawn with an alternate gets its default glyph first. `None` unless the Type tool's selection +/// is exactly one character of one type object whose font is available and gives it another glyph. +pub(crate) fn on_canvas(app: &VectorcraftApp, ui: &Ui) -> Option<OnCanvas> { + let (_, a, b) = super::character::text_editing(app)?; + if a == b || super::character::text_story(app).is_some() { + return None; + } + let target = selected_target(app)?; + let (start, end) = (a.min(b), a.max(b)); + if target.byte != start || start + target.ch.len_utf8() != end { + return None; + } + let db = FontDb::global(); + let face = db.style_face(target.style).filter(|_| db.has_family(&target.style.font_family))?; + let found = alternates_found(app, ui, target.id, start, face.id())?; + let face = db.face_by_id(found.font_id).unwrap_or(face); + let default = (found.default != found.current).then_some(Variant { gid: found.default, feature: [0; 4], apply: None, default: true }); + let others = found.alternates.iter().filter(|a| a.gid != found.default && a.gid != found.current).map(|a| Variant { + gid: a.gid, + feature: a.feature, + apply: a.apply.clone(), + default: false, + }); + let mut cells: Vec<Variant> = default.into_iter().chain(others).collect(); + if cells.is_empty() { + return None; + } + let more = cells.len() > ON_CANVAS_ALTERNATES; + cells.truncate(ON_CANVAS_ALTERNATES); + Some(OnCanvas { + id: target.id, + bytes: (start, end), + face, + cells, + more, + features: target.style.features.clone(), + has_choice: target.style.glyph_alternate.is_some(), + }) +} + +/// Show the Glyphs panel on Show › Alternates for Current Selection, for the canvas's control that +/// stands for the alternates it leaves out. A panel that is already shown stays where it is. +pub(crate) fn show_alternates(app: &mut VectorcraftApp, ctx: &egui::Context) { + set_pstate(ctx, "gl-subset", SUBSETS.len()); + if let Err(e) = crate::panel_docking::reveal_panel(app, "glyphs") { + app.status(e); + } +} + /// The Type tool's selected character, and what its word offers, for a double-click on a glyph no /// character maps in Show › Entire Font ([`Fit::pick`]). struct Fit { diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 6c7382200f..f45ba93410 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -55,6 +55,29 @@ fn start_lists_from_typed_markers_is_a_saved_type_preference() { } } +/// Type › Show Character Alternates is a checkbox in the Type panel, on by default; turning it +/// off is saved, and every bundled language translates its label and the row's two tooltips. +#[test] +fn show_character_alternates_is_a_saved_type_preference() { + const LABEL: &str = "Show Character Alternates"; + let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + assert!(a.session.prefs.show_character_alternates); + open(&mut a, Some("Type")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains(LABEL), "{text}"); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields.get("showCharacterAlternates"), Some(&json!(true))); + a.ui.dialog.as_mut().unwrap().fields.insert("showCharacterAlternates".into(), json!(false)); + confirm(&mut a).unwrap(); + assert!(!a.session.prefs.show_character_alternates); + let saved: Prefs = serde_json::from_value(a.ui.engine_prefs.clone()).unwrap(); + assert!(!saved.show_character_alternates); + for lang in crate::i18n::Lang::all().filter(|l| l.code() != crate::i18n::Lang::EN.code()) { + for source in [LABEL, "Click to replace", "Show all alternates in the Glyphs panel"] { + assert!(crate::i18n::has(lang, source), "{}: {source}", lang.code()); + } + } +} + /// A resting checkbox must have a visible outline in both states and every brightness. #[test] fn preference_checkboxes_draw_outlines_without_hover() { diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..65a47dd12e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6321,6 +6321,10 @@ Pen's second anchor at (150, 431), in line with the square's centre. - `highlightAlternateGlyphs` (off by default): Preferences › Type › Highlight Alternate Glyphs. On, the canvas paints pink behind OpenType substitutions (ligatures, contextual forms). Document Setup's Highlight Substituted Glyphs stays separate. Agents set it with `prefs.set`; the desktop canvas reads it on the next paint. +- `showCharacterAlternates` (on by default): Preferences › Type › Show Character Alternates. On, the desktop canvas + shows the alternate glyphs of the one character selected with a Type tool beside it (see + [Alternate glyphs](#alternate-glyphs)). Off, it shows none. It is VectorCraft's own option. `prefs.set` refuses a + value that is not a boolean. - `highlightSubstitutedFonts` (on by default): Preferences › Type › Highlight Substituted Fonts. On, the canvas paints pink behind text drawn in a substitute for a missing font, in every open document. Off, no document shows that highlight. `document.setup` ignores this key and adds a `warnings` entry that names `prefs.set` to its reply. A file @@ -9431,6 +9435,15 @@ and a glyph that the character's style does not draw (All Caps, Small Caps or an system). Choosing the glyph a character already draws is not an edit. Character styles do not take `glyphAlternate`: `charStyle.new`, `charStyle.edit` and `charStyle.setAttrs` refuse it. +On the desktop canvas, a Type tool with exactly one character selected shows up to five of that character's alternates +in a row beside it, in the same order and without the glyph the character is drawn with; a character drawn with an +alternate gets its default glyph first. A click on a glyph runs the same command as the panel's double-click +(`text.setRangeStyle {featureChanges}`, or `text.setGlyphAlternate` for a glyph no feature draws there), as one undo +step. When the character has more alternates than the row shows, an arrow at the end of the row opens the Glyphs panel +on Alternates for Current Selection. The row has no command of its own: an agent reads the alternates with +`text.glyphAlternates` and applies one with the commands above. The `showCharacterAlternates` preference turns the row +off. + ```json {"name":"run_command","arguments":{"command":"text.glyphAlternates","params":{"id":7,"start":2}}} {"name":"run_command","arguments":{"command":"text.setGlyphAlternate","params":{"id":7,"start":2,"gid":1043}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..80b22d3021 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1353,6 +1353,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes. The grid lists the selected character's alternate glyphs from `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. A double-click applies the feature that draws the variant, or a glyph choice if no supported feature draws it, as detailed below. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Native files keep a glyph choice with the face it was made in; another font or version draws the default glyph. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`. + With a Type tool and exactly one character selected, the canvas shows that character's alternate glyphs in a row beside it, and a click on one replaces the character's glyph, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes ("Replace a character with on-canvas alternate glyphs"). The row shows at most five alternates, and an arrow at its end opens the Glyphs panel on Alternates for Current Selection when the character has more, as the [reference manual](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=479) describes. A click runs the command the panel's double-click runs, as one undo step, so a letter of an Arabic word keeps the word joined. These rules are VectorCraft's own: the row leaves out the glyph the character is drawn with and puts the default glyph first for a character drawn with an alternate; it stands under a character of horizontal type and on the right of a character of vertical type, or on the other side when the canvas ends there or the Contextual Task Bar is there; Preferences › Type › Show Character Alternates (`showCharacterAlternates`, on by default) turns it off. Tests: `canvas::alternates_bar::tests`, `tests::prefs::show_character_alternates_preference_is_on_by_default_and_saved`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. From 66f989c27bb737ea5bc273318aa1b8a91712029b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 10:09:26 -0800 Subject: [PATCH 86/89] Effects: the member limit's doc comment says a compound path with fill or stroke effects becomes a group of pieces --- crates/effects/src/clip.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index d04a60b70a..c5e11c648f 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -37,9 +37,11 @@ pub(crate) fn with_clip_effects_applied(n: &Node) -> Option<Node> { /// [`with_clip_effects_applied`] that calls `take` before it visits each member of a group, layer /// or compound path in `n`, at every depth: once `take` returns `false`, that member and the /// members after it are not visited. The members of a group or layer that are not visited stay as -/// they are, with their effects. A compound path with geometry effects of its own, or of its fills -/// and strokes, still becomes a path of its reshaped geometry: the paths in it that are not visited -/// go into that geometry as they are, and their own effects are not applied. +/// they are, with their effects. A compound path with geometry effects of its own still becomes a +/// path of its reshaped geometry. In a clipping path that does not paint, one whose fills or +/// strokes have geometry effects still becomes a group of one path per fill and stroke. In both +/// cases the paths in it that are not visited go +/// into that geometry as they are, and their own effects are not applied. pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option<Node> { let paint = n.clip_paint(); clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) From 3534dd73431b8fac4cbc1a973d5a531e17aebe98 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 09:35:04 -0800 Subject: [PATCH 87/89] Symbols: measuring and picking in 9-slice instances reuse one set of symbol boxes Measuring an instance of a symbol with 9-slice guides looked its map up through Document::symbol_slice_map, which scanned the symbol list and measured the art box again in new quick boxes, and NineSliceMap::place and most_placed made new quick boxes of their own. Each of those boxes built the symbol name index over the whole list on its first lookup, so every 9-slice instance measured read every symbol once or more. Picking an object inside a 9-slice instance's art with Direct Selection did the same for each object of the art that holds a symbol instance. - SymbolBoxes cuts the guides through the art box it has measured (SymbolBoxes::slice_map), and places and counts the art with itself as the boxes, in the mode quick boxes use. Art with no 9-slice map is moved by the instance's transform without looking the map up again. - Document::symbol_slice_map, place_symbol_art, place_symbol_art_unclipped and edited_nine_slice use one set of quick boxes for each call. - symbol_part_at and symbol_part_outline keep one set of quick boxes for the instance's art. The new tests count the symbols read. Measuring 200 instances of a symbol with guides and 200 of a symbol with guides holding it, among 2,002 symbols, read 1,205,204 symbols before; now each set of boxes reads the list once. Picking in a 9-slice instance whose art holds 20 instances read 84,042 symbols among 2,001; now it reads the list at most twice. --- crates/doc/src/hit.rs | 50 +++++++++++----------- crates/doc/src/hit/tests.rs | 47 +++++++++++++++++++++ crates/doc/src/symbol_nine_slice.rs | 54 ++++++++++++++++-------- crates/doc/src/symbols.rs | 64 +++++++++++++++++++++++++---- 4 files changed, 167 insertions(+), 48 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index fd907a26cc..93b690ccf8 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -974,43 +974,45 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option /// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and /// how the canvas places each object of it: through the symbol's 9-slice guides when the instance /// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. -struct InstanceArt { +struct InstanceArt<'a> { art: Node, map: Option<crate::NineSliceMap>, xf: vectorcraft_geom::Affine, + /// Quick boxes, which measure the instances in the art as it is placed through the guides. + boxes: SymbolBoxes<'a>, } -impl InstanceArt { +impl<'a> InstanceArt<'a> { /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. - fn of(doc: &Document, inst: &Node) -> Option<Self> { + fn of(doc: &'a Document, inst: &Node) -> Option<Self> { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; - let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; - let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); - let map = doc.symbol_slice_map(symbol, *xf); - if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + let boxes = doc.symbol_boxes().quick(); + let art = crate::symbol_overrides::dynamic_art(boxes.symbol_art(symbol)?, inst); + let map = boxes.slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed_with(&art, &boxes) > crate::MAX_SYMBOL_NODES) { return None; } - Some(Self { art, map, xf: *xf }) + Some(Self { art, map, xf: *xf, boxes }) } /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides /// object by object. - fn whole(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<vectorcraft_geom::Affine> { - whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + fn whole(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> Option<vectorcraft_geom::Affine> { + whole.or_else(|| self.map.as_ref()?.one_cell_with(n, &self.boxes)) } /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as /// [`Document::place_symbol_art`] refuses it. - fn place(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> { + fn place(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> { match (&self.map, whole) { (_, Some(a)) => Some(moved(n, a)), - (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (Some(m), None) => (m.most_placed_with(n, &self.boxes) <= crate::MAX_SYMBOL_NODES).then(|| m.place_with(n, &self.boxes)), (None, None) => Some(moved(n, self.xf)), } } @@ -1018,10 +1020,13 @@ impl InstanceArt { /// Object `n` of the art placed on the canvas without clip groups /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the /// same limit ([`Document::place_symbol_art_unclipped`]). - fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> { + fn place_unclipped(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> { match (&self.map, whole) { (_, Some(a)) => Some(moved(n, a)), - (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (Some(m), None) => { + let m = m.unclipped(); + (m.most_placed_with(n, &self.boxes) <= crate::MAX_SYMBOL_NODES).then(|| m.place_with(n, &self.boxes)) + } (None, None) => Some(moved(n, self.xf)), } } @@ -1046,15 +1051,14 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option<Vec<u32>> { struct Find<'a> { - doc: &'a Document, - art: &'a InstanceArt, + art: &'a InstanceArt<'a>, p: Point, opt: HitOptions, symbols: &'a SymbolBoxes<'a>, } impl Find<'_> { fn hit(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> bool { - self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) } fn skips_guide(&self, n: &Node) -> bool { self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) @@ -1067,11 +1071,11 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { return self.hit(n, whole); } - let whole = self.art.whole(n, self.doc, whole); + let whole = self.art.whole(n, whole); if n.clips() && let Some(first) = children.first() { - let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + let Some(clip) = self.art.place_unclipped(first, whole) else { return false }; if self.opt.clip_outlines && first.visible && !first.locked @@ -1103,7 +1107,7 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; - Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) + Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) } /// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), @@ -1123,11 +1127,11 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< let mut n = &art.art; for &i in path { let children = n.children()?; - whole = art.whole(n, doc, whole); + whole = art.whole(n, whole); if i != 0 && n.clips() && let Some(first) = children.first() - && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + && let Some(clip) = art.place_unclipped(first, whole)?.geometric_bounds() { match meet(Some(clip), limit) { Some(r) => limit = Some(r), @@ -1136,7 +1140,7 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< } n = children.get(usize::try_from(i).ok()?)?; } - outline_within(&art.place(part, doc, whole)?, limit, &mut out); + outline_within(&art.place(part, whole)?, limit, &mut out); Some(out) } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 3eac4202b6..a4d1cbbf10 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1586,6 +1586,53 @@ fn symbol_art_past_the_object_limit_picks_nothing() { assert!(symbol_part_outline(&d, n, &[0]).is_some()); } +/// Picking and outlining an object of a 9-slice instance's art, and placing that art, read the +/// symbol list once for each call, however many instances the art holds. +#[test] +fn picking_in_9_slice_art_reads_the_symbol_list_once() { + let mut d = Document::new(400.0, 400.0); + for i in 0..2_000 { + let dot = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()); + d.symbols.push(crate::Symbol { dynamic: false, name: format!("Dot {i}"), art: Arc::new(dot) }); + } + // A frame, a group of 20 squares in its top left cell, and 20 instances of the dots across + // the guides. + let square = |d: &mut Document, x: f64, y: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 4.0, y + 4.0)), Appearance::default_art())) + }; + let corner: Vec<_> = (0..20).map(|i| square(&mut d, f64::from(i % 5) * 4.0, f64::from(i / 5) * 4.0)).collect(); + let dot = |d: &mut Document, i: u32| { + let xf = vectorcraft_geom::Affine::translate((f64::from(i) * 4.5 + 5.0, 48.0)) * vectorcraft_geom::Affine::scale(4.0); + Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("Dot {i}"), xf })) + }; + let across: Vec<_> = (0..20).map(|i| dot(&mut d, i)).collect(); + let frame = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art())); + let mut kids = vec![frame, Arc::new(Node::group(d.alloc_id(), corner))]; + kids.extend(across); + let root = Node::group(d.alloc_id(), kids); + d.symbols.push(crate::Symbol { dynamic: true, name: "Panel".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf }); + let art = d.symbols[2_000].art.clone(); + let symbols = d.symbols.len(); + let read = || crate::symbols::SYMBOLS_READ.with(std::cell::Cell::get); + let reads = |f: &dyn Fn()| { + let before = read(); + f(); + read() - before + }; + // Picking also tests the placed art with boxes of its own, as a click outside symbols does. + let n = reads(&|| assert_eq!(symbol_part_at(&d, &inst, Point::new(2.0, 2.0), HitOptions::default()).map(|p| p[0]), Some(1))); + assert!(n <= 2 * symbols, "picking read {n} symbols"); + let n = reads(&|| assert!(symbol_part_outline(&d, &inst, &[2]).is_some())); + assert!(n <= symbols, "outlining read {n} symbols"); + let n = reads(&|| assert!(d.place_symbol_art("Panel", &art, xf).is_some())); + assert!(n <= symbols, "placing read {n} symbols"); + let n = reads(&|| assert!(d.place_symbol_art_unclipped("Panel", &art, xf).is_some())); + assert!(n <= symbols, "placing without clips read {n} symbols"); +} + /// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it /// included, so an object in it is picked and outlined there even when placing that object through /// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 3c8c1b324d..4877ee7b57 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -463,8 +463,13 @@ impl NineSliceMap { /// /// Strokes keep their weight, as they do for any instance. pub fn place(&self, art: &Node, doc: &Document) -> Node { - let boxes = doc.symbol_boxes().quick(); - self.place_at(art, &boxes, 0) + self.place_with(art, &doc.symbol_boxes().quick()) + } + + /// [`Self::place`] with the instances in `art` measured by `boxes`, quick boxes or boxes + /// measuring symbol art. + pub(crate) fn place_with(&self, art: &Node, boxes: &SymbolBoxes) -> Node { + self.place_at(art, boxes, 0) } /// [`Self::place`] without clip groups, for writers that do not apply clipping (DXF, WMF): @@ -480,16 +485,25 @@ impl NineSliceMap { /// object drawn in clip groups counts one group plus a clip path and a copy for each cell its /// box reaches. That is never more than [`MAX_SLICE_GROWTH`] for each object of `art`. pub fn most_placed(&self, art: &Node, doc: &Document) -> u64 { - let boxes = doc.symbol_boxes().quick(); - self.most_at(art, &boxes, 0) + self.most_placed_with(art, &doc.symbol_boxes().quick()) + } + + /// [`Self::most_placed`] with the instances in `art` measured by `boxes`, quick boxes or boxes + /// measuring symbol art. + pub(crate) fn most_placed_with(&self, art: &Node, boxes: &SymbolBoxes) -> u64 { + self.most_at(art, boxes, 0) } /// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one /// piece, with everything in it: the map of the cell its box lies in, or of the cell at the /// origin when it has no box. `None` when its box crosses a guide. pub fn one_cell(&self, n: &Node, doc: &Document) -> Option<Affine> { - let boxes = doc.symbol_boxes().quick(); - let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + self.one_cell_with(n, &doc.symbol_boxes().quick()) + } + + /// [`Self::one_cell`] with the instances in `n` measured by `boxes`. + pub(crate) fn one_cell_with(&self, n: &Node, boxes: &SymbolBoxes) -> Option<Affine> { + let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { return Some(self.cell_at(Point::ZERO)); }; let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); @@ -501,6 +515,11 @@ impl NineSliceMap { Self { clips: false, ..*self }.most_placed(art, doc) } + /// This map placing without clip groups, as [`Self::place_unclipped`] does. + pub(crate) fn unclipped(&self) -> Self { + Self { clips: false, ..*self } + } + /// [`Self::most_placed`] for `n` at nesting depth `depth`, following [`Self::place_at`]. fn most_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { let whole = n.count_with_masks() as u64; @@ -671,7 +690,7 @@ impl NineSliceMap { } /// `n` moved by `a`, its strokes at their own weight. -fn transformed(n: &Node, a: Affine) -> Node { +pub(crate) fn transformed(n: &Node, a: Affine) -> Node { let mut out = n.clone(); out.transform(a, false); out @@ -765,10 +784,7 @@ impl Document { /// `None` when the symbol has no guides or the instance is at its natural size. The guides cut /// the art's box with the instances in it measured by their own art. pub fn symbol_slice_map(&self, name: &str, xf: Affine) -> Option<NineSliceMap> { - let guides = self.symbol_nine_slice(name)?; - let sym = self.symbols.iter().find(|s| s.name == name)?; - let art = self.symbol_boxes().quick().art_bounds(name).or_else(|| sym.art.geometric_bounds())?; - NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf) + self.symbol_boxes().quick().slice_map(name, xf) } /// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already @@ -782,9 +798,10 @@ impl Document { /// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through /// the guides could make more than [`MAX_SYMBOL_NODES`] objects ([`NineSliceMap::most_placed`]). pub fn place_symbol_art(&self, name: &str, art: &Node, xf: Affine) -> Option<Node> { - match self.symbol_slice_map(name, xf) { - Some(m) if m.most_placed(art, self) > MAX_SYMBOL_NODES => None, - Some(m) => Some(m.place(art, self)), + let boxes = self.symbol_boxes().quick(); + match boxes.slice_map(name, xf) { + Some(m) if m.most_placed_with(art, &boxes) > MAX_SYMBOL_NODES => None, + Some(m) => Some(m.place_with(art, &boxes)), None => Some(transformed(art, xf)), } } @@ -795,8 +812,8 @@ impl Document { pub fn edited_nine_slice(&self) -> Option<EditedNineSlice> { let se = self.symbol_edit.as_ref()?; let guides = self.symbol_nine_slice(&se.symbol)?; - let sym = self.symbols.iter().find(|s| s.name == se.symbol)?; - let art = self.symbol_boxes().quick().art_bounds(&se.symbol).or_else(|| sym.art.geometric_bounds())?; + let boxes = self.symbol_boxes().quick(); + let art = boxes.art_bounds(&se.symbol).or_else(|| boxes.symbol_art(&se.symbol)?.geometric_bounds())?; let natural = self.symbol_natural_xf(&se.symbol); let b = natural.transform_rect_bbox(art); if !finite(b) || b.width() <= 1e-9 || b.height() <= 1e-9 || natural.determinant().abs() < 1e-18 { @@ -811,8 +828,9 @@ impl Document { /// [`Self::place_symbol_art`] for writers that do not apply clipping /// ([`NineSliceMap::place_unclipped`]). pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option<Node> { - match self.symbol_slice_map(name, xf) { - Some(m) => (m.most_placed_unclipped(art, self) <= MAX_SYMBOL_NODES).then(|| m.place_unclipped(art, self)), + let boxes = self.symbol_boxes().quick(); + match boxes.slice_map(name, xf).map(|m| m.unclipped()) { + Some(m) => (m.most_placed_with(art, &boxes) <= MAX_SYMBOL_NODES).then(|| m.place_with(art, &boxes)), None => Some(transformed(art, xf)), } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 822dce49ea..944accfe4b 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -8,7 +8,8 @@ use std::sync::Arc; use vectorcraft_geom::{Affine, Rect}; -use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF}; +use crate::symbol_nine_slice::transformed; +use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, NineSliceMap, Node, NodeKind, SYMBOL_HALF}; /// The box a symbol instance is measured by when its symbol's art has no box /// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin @@ -128,6 +129,8 @@ impl Document { /// Each symbol name of this document → the index of the first symbol of that name (the one a /// name finds). pub(crate) fn symbol_names(&self) -> SymbolNames { + #[cfg(test)] + SYMBOLS_READ.with(|c| c.set(c.get() + self.symbols.len())); let mut names = HashMap::with_capacity(self.symbols.len()); for (i, s) in self.symbols.iter().enumerate() { names.entry(s.name.clone()).or_insert(i); @@ -136,6 +139,12 @@ impl Document { } } +#[cfg(test)] +thread_local! { + /// How many symbols [`Document::symbol_names`] has read on this thread. + pub(crate) static SYMBOLS_READ: Cell<usize> = const { Cell::new(0) }; +} + impl<'a> SymbolBoxes<'a> { /// The document whose symbols these are. pub fn document(&self) -> &'a Document { @@ -437,8 +446,8 @@ impl<'a> SymbolBoxes<'a> { // The most objects placing makes, the art of opacity masks included, is set aside // while the art is placed and measured, and what placing did not make is given back. let art = self.symbol_art(name)?; - let most = match self.doc.symbol_slice_map(name, linear) { - Some(m) => m.most_placed(art, self.doc), + let most = match self.slice_map(name, linear) { + Some(m) => self.in_art(|| m.most_placed_with(art, self)), None => art.count_with_masks() as u64, }; let used = self.sliced_placed.get(); @@ -455,6 +464,14 @@ impl<'a> SymbolBoxes<'a> { v.map(|v| moved.transform_rect_bbox(v)) } + /// [`Document::symbol_slice_map`] with the guides cutting the art box these boxes measure for + /// the symbol (its geometric part, which does not depend on the mode of the boxes). + pub(crate) fn slice_map(&self, name: &str, xf: Affine) -> Option<NineSliceMap> { + let guides = self.doc.symbol_nine_slice(name)?; + let art = self.art_bounds(name).or_else(|| self.symbol_art(name)?.geometric_bounds())?; + NineSliceMap::new(guides, art, self.doc.symbol_natural_xf(name), xf) + } + /// The box [`Self::placed_visual_within`] returns outside quick boxes. fn place_and_measure(&self, name: &str, xf: Affine, most: u64) -> Option<Rect> { self.place_and_count(name, xf, most).and_then(|(v, _)| v) @@ -464,10 +481,10 @@ impl<'a> SymbolBoxes<'a> { /// opacity masks included. `None` when the art is not placed. fn place_and_count(&self, name: &str, xf: Affine, most: u64) -> Option<(Option<Rect>, u64)> { self.measure_art(name, |art| { - let art = match self.doc.symbol_slice_map(name, xf) { - Some(m) if m.most_placed(art, self.doc) > most => return None, - Some(m) => m.place(art, self.doc), - None => self.doc.place_symbol_art(name, art, xf)?, + let art = match self.slice_map(name, xf) { + Some(m) if self.in_art(|| m.most_placed_with(art, self)) > most => return None, + Some(m) => self.in_art(|| m.place_with(art, self)), + None => transformed(art, xf), }; Some((self.in_art(|| art.visual(Some(self))), art.count_with_masks() as u64)) }) @@ -689,6 +706,39 @@ mod tests { assert_eq!(s.sliced_placed.get(), 13); } + #[test] + fn measuring_9_slice_instances_reads_the_symbol_list_once() { + // 2,000 symbols, then a frame with guides and a symbol with guides holding the frame + // scaled 2 times across, each shown by 200 instances scaled by different amounts. + let mut d = Document::new(200.0, 200.0); + for i in 0..2000 { + let dot = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0); + symbol(&mut d, &format!("Dot {i}"), vec![dot]); + } + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0); + symbol(&mut d, "Panel", vec![frame]); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let wide = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + symbol(&mut d, "Holder", vec![wide]); + d.set_symbol_nine_slice("Holder", Some(crate::SymbolNineSlice::default())); + let scaled = |d: &mut Document, name: &str| -> Vec<Node> { + (1..=200).map(|i| instance(d, name, Affine::scale_non_uniform(1.0 + f64::from(i) / 100.0, 1.5))).collect() + }; + let (panels, holders) = (scaled(&mut d, "Panel"), scaled(&mut d, "Holder")); + // What each instance measures in boxes of its own. + let exact: Vec<_> = panels.iter().map(|n| n.visual_bounds_with(&d.symbol_boxes())).collect(); + let quick: Vec<_> = holders.iter().map(|n| n.visual_bounds_with(&d.symbol_boxes().quick())).collect(); + let read = || SYMBOLS_READ.with(Cell::get); + let before = read(); + let s = d.symbol_boxes(); + assert_eq!(panels.iter().map(|n| n.visual_bounds_with(&s)).collect::<Vec<_>>(), exact); + let s = d.symbol_boxes().quick(); + assert_eq!(holders.iter().map(|n| n.visual_bounds_with(&s)).collect::<Vec<_>>(), quick); + // Each set of boxes reads the list once to find symbols by name. + let symbols = d.symbols.len(); + assert!(read() - before <= 2 * symbols, "{} symbols read", read() - before); + } + #[test] fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() { let mut d = Document::new(200.0, 200.0); From bada8e17103db9917de44e40a1cead5a7fc71c3c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" <dylan@dylanmtaylor.com> Date: Sat, 14 Nov 2026 13:49:50 -0500 Subject: [PATCH 88/89] Cut over roxmltree, png, tiff, psd, and trace codecs to craft-libs (#7638) --- Cargo.lock | 154 ++++++++++++++++-- Cargo.toml | 5 + crates/engine/Cargo.toml | 2 +- crates/engine/src/cmd/place/docx.rs | 152 ++++++----------- crates/engine/src/cmd/variablecmds/library.rs | 45 +++-- crates/pdf/Cargo.toml | 4 +- crates/pdf/src/file_info.rs | 125 +++++--------- crates/render/Cargo.toml | 8 +- crates/render/src/encode/tests.rs | 6 +- crates/render/src/encode/tests_psd.rs | 25 +-- crates/render/src/encode/tests_tiff.rs | 98 ++++++----- crates/render/src/paint.rs | 101 ++++++++---- crates/trace/Cargo.toml | 2 +- crates/trace/src/opaque.rs | 98 +++++++---- crates/trace/src/raster.rs | 112 ++++++++----- crates/trace/src/tests.rs | 18 +- 16 files changed, 562 insertions(+), 393 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3e85a08a7..c8d3381d6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bit_field" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1" + [[package]] name = "bitflags" version = "1.3.2" @@ -1033,6 +1039,36 @@ dependencies = [ "writeable", ] +[[package]] +name = "craft-image-codecs" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" +dependencies = [ + "craft-jpeg", + "exr", + "flate2", + "half", + "image", + "image-webp", + "jpeg-encoder", + "png 0.18.1", + "rayon", + "serde_json", + "thiserror 2.0.21", + "tiff", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", + "zune-jpegxl", +] + +[[package]] +name = "craft-jpeg" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-keymap" version = "0.1.0" @@ -1079,12 +1115,23 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "craft-psd" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" +dependencies = [ + "encoding_rs", + "flate2", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "craft-svg" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" dependencies = [ - "craft-xml", + "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2)", "flate2", "kurbo 0.13.1", "roxmltree 0.21.2", @@ -1094,6 +1141,15 @@ dependencies = [ "vectorcraft-pathops 0.18.0", ] +[[package]] +name = "craft-tiff" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" +dependencies = [ + "serde", + "thiserror 2.0.21", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1192,6 +1248,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -1704,6 +1769,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "multiversion", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -2736,6 +2817,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "qoi", "tiff", "zune-core 0.5.3", "zune-jpeg 0.5.15", @@ -3189,6 +3271,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.191" @@ -3388,6 +3476,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -4338,21 +4448,21 @@ dependencies = [ "unarray", ] -[[package]] -name = "psd" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195" -dependencies = [ - "thiserror 1.0.69", -] - [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -5723,6 +5833,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tempfile" version = "3.27.0" @@ -6535,6 +6651,7 @@ dependencies = [ "chrono", "craft-docpath", "craft-proof", + "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "crc32fast", "flate2", "hayro-jpeg2000", @@ -6547,7 +6664,6 @@ dependencies = [ "log", "proptest", "rayon", - "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", "same-file", @@ -6721,6 +6837,7 @@ dependencies = [ "aes", "chrono", "craft-pdf", + "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "flate2", "getrandom 0.4.3", "image", @@ -6731,7 +6848,6 @@ dependencies = [ "md-5", "objc2-core-foundation", "objc2-core-graphics", - "roxmltree 0.21.2", "serde", "serde_json", "sha2", @@ -6772,6 +6888,9 @@ dependencies = [ name = "vectorcraft-render" version = "0.24.1" dependencies = [ + "craft-image-codecs", + "craft-psd", + "craft-tiff", "flate2", "gif 0.14.2", "image", @@ -6779,9 +6898,7 @@ dependencies = [ "log", "png 0.18.1", "proptest", - "psd", "serde_json", - "tiff", "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", @@ -8356,6 +8473,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.4.21" diff --git a/Cargo.toml b/Cargo.toml index c7856d589e..109f4d6f71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,11 @@ craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } # DXF tags, reader and writer (craft-libs#778, #926, #927). craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } +craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } +craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } +craft-gif = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } +craft-tiff = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } +craft-psd = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0fccbcdecb..b46678804b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -53,7 +53,7 @@ heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], op flate2 = { workspace = true } chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). -roxmltree = "0.21" +craft-xml = { workspace = true } # The compound file that holds a Word 97-2003 document placed as type (place/doc.rs). cfb = "0.15" kurbo = { workspace = true } diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index 063444442e..ac528f3735 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -38,7 +38,7 @@ use std::collections::HashMap; -use roxmltree::Node; +use craft_xml::{Canonical, Doctype, El, Limits, Names, Options}; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; @@ -88,6 +88,7 @@ const MAX_RUNS: usize = 100_000; /// WordprocessingML's namespaces: Transitional, then Strict. const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]; +const DOCX_NS: Canonical<'static> = Canonical::new(&[("w", W[0]), ("w", W[1])]); /// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds /// `word/document.xml`? @@ -104,7 +105,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> { let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into()); let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) }; let doc = parse(&xml, &main, cmd)?; - if local(doc.root_element()) != Some("document") { + if local(&doc) != Some("document") { return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document")); } let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default(); @@ -117,7 +118,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> { if let Some(xml) = part(&zip, &styles, cmd)? { r.styles(&parse(&xml, &styles, cmd)?); } - if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) { + if let Some(body) = child(&doc, "body") { r.block(body, 0); } r.finish(cmd) @@ -158,57 +159,17 @@ fn part(zip: &Archive, name: &str, cmd: &str) -> Result<Option<String>> { } /// The XML of the part `name`. -fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<'a>> { - // roxmltree parses nested elements recursively. - if nesting(xml) > MAX_NESTING { - return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep"))); - } - let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() }; - roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}"))) -} - -/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, -/// CDATA sections, processing instructions, declarations and quoted attribute values. -pub(crate) fn nesting(xml: &str) -> usize { - let b = xml.as_bytes(); - let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); - // The index just past the first `end` at or after `from` (the end of the text if none). - let past = |from: usize, end: &[u8]| { - b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) +fn parse(xml: &str, name: &str, cmd: &str) -> Result<El> { + let opts = Options { + names: Names::Canonical(&DOCX_NS), + doctype: Doctype::Reject, + limits: Limits { max_depth: MAX_NESTING, max_nodes: MAX_NODES as usize, ..Limits::DEFAULT }, + ..Options::default() }; - while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { - let start = i + at; - let rest = b.get(start..).unwrap_or_default(); - if rest.starts_with(b"<!--") { - i = past(start + 4, b"-->"); - } else if rest.starts_with(b"<![CDATA[") { - i = past(start + 9, b"]]>"); - } else if rest.starts_with(b"<?") { - i = past(start + 2, b"?>"); - } else if rest.starts_with(b"<!") || rest.starts_with(b"</") { - depth = depth.saturating_sub(usize::from(rest.starts_with(b"</"))); - i = past(start + 2, b">"); - } else { - // A start tag: up to its `>` outside quotes; `/>` closes it at once. - let mut j = start + 1; - let mut quote = None; - while let Some(&c) = b.get(j) { - match (quote, c) { - (None, b'"' | b'\'') => quote = Some(c), - (Some(q), _) if c == q => quote = None, - (None, b'>') => break, - _ => {} - } - j += 1; - } - if b.get(j.saturating_sub(1)) != Some(&b'/') { - depth += 1; - most = most.max(depth); - } - i = j + 1; - } - } - most + opts.parse(xml.as_bytes()).map_err(|e| match e { + craft_xml::Error::TooDeep(_) => bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")), + _ => bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")), + }) } /// The part that the first relationship of part `source` (`""`: the package) whose type ends in @@ -220,11 +181,10 @@ fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result<Op let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) }; let doc = parse(&xml, &rels, cmd)?; let target = doc - .root_element() - .children() - .filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External")) - .find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind))) - .and_then(|n| n.attribute("Target")); + .elements() + .filter(|n| n.local() == "Relationship" && n.attr("TargetMode") != Some("External")) + .find(|n| n.attr("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind))) + .and_then(|n| n.attr("Target")); Ok(target.map(|t| resolve(folder, t))) } @@ -248,22 +208,22 @@ fn resolve(folder: &str, target: &str) -> String { } /// `n`'s local name when it is a WordprocessingML element. -fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> { - (n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name()) +fn local(n: &El) -> Option<&str> { + n.name.strip_prefix("w:") } /// `n`'s first WordprocessingML child element named `name`. -fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<Node<'a, 'i>> { - n.children().find(|c| local(*c) == Some(name)) +fn child<'a>(n: &'a El, name: &str) -> Option<&'a El> { + n.elements().find(|c| local(c) == Some(name)) } /// `n`'s WordprocessingML attribute `name`. -fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> { - n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value()) +fn attr<'a>(n: &'a El, name: &str) -> Option<&'a str> { + n.attrs.iter().find_map(|(k, v)| k.strip_prefix("w:").filter(|&l| l == name).map(|_| v.as_str())) } /// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`. -fn on(n: Node) -> bool { +fn on(n: &El) -> bool { !matches!(attr(n, "val"), Some("false" | "0" | "off")) } @@ -280,12 +240,12 @@ fn measure(v: &str, per_pt: f64) -> Option<f64> { } /// Twentieths of a point. -fn twips(n: Node, name: &str) -> Option<f64> { +fn twips(n: &El, name: &str) -> Option<f64> { attr(n, name).and_then(|v| measure(v, 20.0)) } /// Half points. -fn half_points(n: Node) -> Option<f64> { +fn half_points(n: &El) -> Option<f64> { attr(n, "val").and_then(|v| measure(v, 2.0)) } @@ -462,21 +422,21 @@ struct Reader { impl Reader { /// The major and minor fonts of a theme part's font scheme. - fn theme_fonts(&mut self, doc: &roxmltree::Document) { + fn theme_fonts(&mut self, doc: &El) { for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) { - *slot = doc - .descendants() - .find(|n| n.tag_name().name() == scheme) - .and_then(|n| n.children().find(|c| c.tag_name().name() == "latin")) - .and_then(|n| n.attribute("typeface")) + *slot = std::iter::once(doc) + .chain(doc.descendants()) + .find(|n| n.local() == scheme) + .and_then(|n| n.elements().find(|c| c.local() == "latin")) + .and_then(|n| n.attr("typeface")) .map(font_name) .filter(|f| !f.is_empty()); } } /// The document defaults and the styles of the styles part. - fn styles(&mut self, doc: &roxmltree::Document) { - for n in doc.root_element().children() { + fn styles(&mut self, doc: &El) { + for n in doc.elements() { match local(n) { Some("docDefaults") => { let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner)); @@ -526,7 +486,7 @@ impl Reader { } /// The number of the font a `w:rFonts` element names. - fn font(&mut self, n: Node) -> Option<usize> { + fn font(&mut self, n: &El) -> Option<usize> { let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref); let name = attr(n, "asciiTheme") .and_then(theme) @@ -549,9 +509,9 @@ impl Reader { } /// The run properties of a `w:rPr` element. - fn run_props(&mut self, rpr: Node) -> RunProps { + fn run_props(&mut self, rpr: &El) -> RunProps { let mut p = RunProps::default(); - for n in rpr.children() { + for n in rpr.elements() { match local(n) { Some("rFonts") => p.font = self.font(n), Some("b") => p.bold = Some(on(n)), @@ -594,11 +554,11 @@ impl Reader { } /// The paragraphs, tables and containers of them that are `n`'s children. - fn block(&mut self, n: Node, depth: usize) { + fn block(&mut self, n: &El, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.children() { + for c in n.elements() { if self.full { return; } @@ -617,7 +577,7 @@ impl Reader { } /// A table: each row a paragraph, its cells separated by tabs. - fn table(&mut self, tbl: Node, depth: usize) { + fn table(&mut self, tbl: &El, depth: usize) { for row in items(tbl, "tr", depth) { for cell in items(row, "tc", depth) { self.flush(); @@ -638,7 +598,7 @@ impl Reader { } /// A paragraph. - fn paragraph(&mut self, p: Node, depth: usize) { + fn paragraph(&mut self, p: &El, depth: usize) { self.flush(); let ppr = child(p, "pPr"); let style = ppr @@ -675,11 +635,11 @@ impl Reader { } /// The runs of a paragraph, and the containers of runs in it. - fn inline(&mut self, n: Node, depth: usize) { + fn inline(&mut self, n: &El, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.children() { + for c in n.elements() { match local(c) { Some("r") => self.run(c), Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1), @@ -694,7 +654,7 @@ impl Reader { } /// A run. - fn run(&mut self, r: Node) { + fn run(&mut self, r: &El) { let rpr = child(r, "rPr"); let mut chars = self.base; let style = rpr @@ -709,11 +669,10 @@ impl Reader { let props = self.run_props(n); chars.apply(&props, false); } - for c in r.children() { + for c in r.elements() { match local(c) { Some("t") => { - // Comments and processing instructions may split the text. - for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) { + for ch in c.text().chars() { self.put(ch, chars); } } @@ -907,9 +866,9 @@ fn font_name(name: &str) -> String { } /// The paragraph properties of a `w:pPr` element. -fn para_props(ppr: Node) -> ParaProps { +fn para_props(ppr: &El) -> ParaProps { let mut p = ParaProps::default(); - for n in ppr.children() { + for n in ppr.elements() { match local(n) { Some("jc") => { p.align = match attr(n, "val") { @@ -945,12 +904,12 @@ fn para_props(ppr: Node) -> ParaProps { } /// `n`'s children named `name`, and those inside content controls and custom XML among them. -fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec<Node<'a, 'i>> { +fn items<'a>(n: &'a El, name: &str, depth: usize) -> Vec<&'a El> { let mut out = vec![]; if depth > MAX_DEPTH { return out; } - for c in n.children() { + for c in n.elements() { match local(c) { Some(l) if l == name => out.push(c), Some("sdt") => { @@ -979,15 +938,6 @@ mod tests { assert_eq!(measure("wide", 20.0), None); } - #[test] - fn nesting_counts_elements_outside_markup_that_is_not_an_element() { - assert_eq!(nesting(r#"<?xml version="1.0"?><a><b x="1>2" y='</c>'><c/></b><!-- <d><e> --><![CDATA[<f><g>]]></a>"#), 2); - assert_eq!(nesting("<a><b><c>"), 3, "unclosed elements count"); - assert_eq!(nesting("</a></b><a>"), 1); - assert_eq!(nesting("<a x=\"unterminated"), 1); - assert_eq!(nesting(""), 0); - } - #[test] fn targets_resolve_against_their_folder() { assert_eq!(resolve("", "word/document.xml"), "word/document.xml"); diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index e1f4ae0bd5..63c1a6f382 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -198,31 +198,32 @@ impl Library { /// [`without_doctype`] says. fn parse_library(xml: &str, cmd: &str) -> Result<Library> { let xml = without_doctype(xml, cmd)?; - // roxmltree parses nested elements recursively. - if crate::cmd::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING { - return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep"))); - } - let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() }; - let doc = - roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; - let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else { + let opts = craft_xml::Options { + names: craft_xml::Names::Local, + limits: craft_xml::Limits { max_depth: MAX_LIBRARY_NESTING, max_nodes: MAX_LIBRARY_NODES as usize, ..craft_xml::Limits::DEFAULT }, + ..craft_xml::Options::default() + }; + let doc = opts.parse(xml.as_bytes()).map_err(|e| match e { + craft_xml::Error::TooDeep(_) => bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep")), + _ => bad(cmd, format!("the variable library is not well-formed XML: {e}")), + })?; + let Some(root) = std::iter::once(&doc).chain(doc.descendants()).find(|n| n.is("variableSets")) else { return Err(bad(cmd, "the file holds no `variableSets` element")); }; - let sets: Vec<roxmltree::Node> = root.children().filter(|n| is(n, "variableSet")).collect(); + let sets: Vec<&craft_xml::El> = root.children("variableSet").collect(); let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); - for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { - let name = named(var.attribute("varName"), "varName", cmd)?; + for var in sets.iter().flat_map(|s| s.children("variables")).flat_map(|v| v.children("variable")) { + let name = named(var.attr("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { return Err(bad(cmd, format!("variable `{name}` is declared twice"))); } if declared.len() > MAX_LIBRARY_VARIABLES { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } - match var.attribute("trait") { + match var.attr("trait") { Some("textcontent") => { // `docRef="id('…')"` names the element of the variable's bound object. - let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + let element = var.attr("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); if let Some(element) = element { refs.push((name.clone(), element.to_string())); } @@ -237,10 +238,8 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { } let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect(); let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new()); - for row in - sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet")) - { - let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?; + for row in sets.iter().flat_map(|s| s.children("sampleDataSets")).flat_map(|d| d.children("sampleDataSet")) { + let name = named(row.attr("dataSetName"), "dataSetName", cmd)?; if !seen.insert(name.clone()) { return Err(bad(cmd, format!("data set `{name}` is declared twice"))); } @@ -248,8 +247,8 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets"))); } let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new()); - for el in row.children().filter(roxmltree::Node::is_element) { - let var = el.tag_name().name(); + for el in row.elements() { + let var = el.name.as_str(); if !declared.contains(var) { return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); } @@ -259,11 +258,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { if !text_names.contains(var) { continue; } - let paras: Vec<String> = el - .children() - .filter(|n| is(n, "p")) - .map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()) - .collect(); + let paras: Vec<String> = el.children("p").map(craft_xml::El::deep_text).collect(); if !paras.is_empty() { values.insert(var.to_string(), DataValue::Text(paras.join("\n"))); } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index d4394dfefa..d59243eb75 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -33,8 +33,8 @@ smallvec = "1" siphasher = "1" weezl = "0.2" serde = { workspace = true } -# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses. -roxmltree = "0.21" +# The XMP packet that File Info is read from (file_info.rs). +craft-xml = { workspace = true } # PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm. aes = "0.8" md-5 = { version = "0.10", default-features = false } diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 1cb0a45273..908212aabe 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -5,7 +5,7 @@ use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{DateTime, Dict, Stream}; -use roxmltree::Node; +use craft_xml::{Canonical, Doctype, El, Limits, Names, Options}; use crate::import_scan::text_string; @@ -15,12 +15,14 @@ const MAX_XMP: usize = 8 << 20; const MAX_NESTING: usize = 256; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; -const XML: &str = "http://www.w3.org/XML/1998/namespace"; pub(crate) const DC: &str = "http://purl.org/dc/elements/1.1/"; const XMP: &str = "http://ns.adobe.com/xap/1.0/"; // brand-ok: the XMP namespace the files use pub(crate) const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace the files use +const XMP_NAMESPACES: Canonical<'static> = + Canonical::new(&[("rdf", RDF), ("dc", DC), ("xmp", XMP), ("xmpRights", XMP_RIGHTS), ("pdf", PDF)]); + /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { @@ -73,39 +75,39 @@ fn packet(pdf: &Pdf) -> Option<Vec<u8>> { } /// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16, or when its -/// elements nest more than [`MAX_NESTING`] deep. +/// [`xmp`] of `packet`; `None` when it isn't XML in UTF-8 or UTF-16, or when its elements nest +/// more than [`MAX_NESTING`] deep. fn xmp(packet: &[u8]) -> Option<FileInfo> { let text = xml_text(packet)?; - // roxmltree parses nested elements recursively. - if nesting(&text) > MAX_NESTING { - return None; + let opts = Options { + names: Names::Canonical(&XMP_NAMESPACES), + doctype: Doctype::Reject, + limits: Limits { max_depth: MAX_NESTING, ..Limits::DEFAULT }, + ..Options::default() } - let xml = roxmltree::Document::parse(&text).ok()?; + .strict(); + let root = opts.parse(text.as_bytes()).ok()?; // The resource's own descriptions: those right under `rdf:RDF`, not the ones inside a value. - let descriptions: Vec<Node<'_, '_>> = xml - .descendants() - .filter(|n| n.has_tag_name((RDF, "RDF"))) - .flat_map(|rdf| rdf.children()) - .filter(|n| n.has_tag_name((RDF, "Description"))) - .collect(); - let get = |ns: &str, name: &str| property(&descriptions, ns, name); - let first = |ns: &str, name: &str| get(ns, name).into_iter().next(); - let creators = get(DC, "creator"); - let subjects = get(DC, "subject"); + let descriptions: Vec<&El> = + std::iter::once(&root).chain(root.descendants()).filter(|n| n.is("rdf:RDF")).flat_map(|rdf| rdf.children("rdf:Description")).collect(); + let get = |name: &str| property(&descriptions, name); + let first = |name: &str| get(name).into_iter().next(); + let creators = get("dc:creator"); + let subjects = get("dc:subject"); Some(FileInfo { - title: first(DC, "title"), + title: first("dc:title"), author: (!creators.is_empty()).then(|| creators.join(", ")), - description: first(DC, "description"), - keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, - copyright_notice: first(DC, "rights"), - copyright_url: first(XMP_RIGHTS, "WebStatement"), - copyrighted: first(XMP_RIGHTS, "Marked").and_then(|m| match m.to_ascii_lowercase().as_str() { + description: first("dc:description"), + keywords: if subjects.is_empty() { first("pdf:Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, + copyright_notice: first("dc:rights"), + copyright_url: first("xmpRights:WebStatement"), + copyrighted: first("xmpRights:Marked").and_then(|m| match m.to_ascii_lowercase().as_str() { "true" => Some(true), "false" => Some(false), _ => None, }), - created: first(XMP, "CreateDate").and_then(|d| xmp_date(&d)), - modified: first(XMP, "ModifyDate").and_then(|d| xmp_date(&d)), + created: first("xmp:CreateDate").and_then(|d| xmp_date(&d)), + modified: first("xmp:ModifyDate").and_then(|d| xmp_date(&d)), }) } @@ -123,58 +125,14 @@ fn xml_text(b: &[u8]) -> Option<String> { } } -/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, -/// CDATA sections, processing instructions, declarations and quoted attribute values. -fn nesting(xml: &str) -> usize { - let b = xml.as_bytes(); - let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); - // The index just past the first `end` at or after `from` (the end of the text if none). - let past = |from: usize, end: &[u8]| { - b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) - }; - while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { - let start = i + at; - let rest = b.get(start..).unwrap_or_default(); - if rest.starts_with(b"<!--") { - i = past(start + 4, b"-->"); - } else if rest.starts_with(b"<![CDATA[") { - i = past(start + 9, b"]]>"); - } else if rest.starts_with(b"<?") { - i = past(start + 2, b"?>"); - } else if rest.starts_with(b"<!") || rest.starts_with(b"</") { - depth = depth.saturating_sub(usize::from(rest.starts_with(b"</"))); - i = past(start + 2, b">"); - } else { - // A start tag: up to its `>` outside quotes; `/>` closes it at once. - let mut j = start + 1; - let mut quote = None; - while let Some(&c) = b.get(j) { - match (quote, c) { - (None, b'"' | b'\'') => quote = Some(c), - (Some(q), _) if c == q => quote = None, - (None, b'>') => break, - _ => {} - } - j += 1; - } - if b.get(j.saturating_sub(1)) != Some(&b'/') { - depth += 1; - most = most.max(depth); - } - i = j + 1; - } - } - most -} - -/// The values of property `ns` `name` on the first of `descriptions` that gives it: an attribute +/// The values of property `name` on the first of `descriptions` that gives it: an attribute /// of the `rdf:Description`, or a property element's items ([`items`]), blank ones left out. -fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec<String> { +fn property(descriptions: &[&El], name: &str) -> Vec<String> { for d in descriptions { - if let Some(v) = d.attribute((ns, name)).map(str::trim).filter(|v| !v.is_empty()) { + if let Some(v) = d.attr(name).map(str::trim).filter(|v| !v.is_empty()) { return vec![v.to_string()]; } - let found = d.children().filter(|c| c.has_tag_name((ns, name))).map(items).find(|v| !v.is_empty()); + let found = d.children(name).map(items).find(|v| !v.is_empty()); if let Some(v) = found { return v; } @@ -185,13 +143,12 @@ fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec<String> /// The items of property element `p`: the `rdf:li` of its `rdf:Seq`, `rdf:Bag` or `rdf:Alt` (the /// `x-default` language item first), else its own value ([`value_of`]); trimmed, blank ones left /// out. -fn items(p: Node<'_, '_>) -> Vec<String> { - let array = p.children().find(|c| ["Seq", "Bag", "Alt"].into_iter().any(|t| c.has_tag_name((RDF, t)))); +fn items(p: &El) -> Vec<String> { + let array = p.elements().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")); let mut values: Vec<(bool, String)> = match array { Some(a) => a - .children() - .filter(|c| c.has_tag_name((RDF, "li"))) - .map(|li| (li.attribute((XML, "lang")).is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li))) + .children("rdf:li") + .map(|li| (li.attr("xml:lang").is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li))) .collect(), None => vec![(false, value_of(p))], }; @@ -203,19 +160,19 @@ fn items(p: Node<'_, '_>) -> Vec<String> { /// The value of element `n`: the URI of its `rdf:resource` attribute (how RDF writes a URI value); /// for a qualified value, its `rdf:value` (an attribute or an element, on `n` or on an /// `rdf:Description` inside it), without the qualifiers; else the text inside it. -fn value_of(mut n: Node<'_, '_>) -> String { +fn value_of(mut n: &El) -> String { // Each step goes to an element inside `n`, so the loop ends. loop { - if let Some(uri) = n.attribute((RDF, "resource")) { + if let Some(uri) = n.attr("rdf:resource") { return uri.to_string(); } - let holder = n.children().find(|c| c.has_tag_name((RDF, "Description"))).unwrap_or(n); - if let Some(v) = holder.attribute((RDF, "value")) { + let holder = n.child("rdf:Description").unwrap_or(n); + if let Some(v) = holder.attr("rdf:value") { return v.to_string(); } - match holder.children().find(|c| c.has_tag_name((RDF, "value"))) { + match holder.child("rdf:value") { Some(v) => n = v, - None => return n.descendants().filter(|d| d.is_text()).filter_map(|d| d.text()).collect(), + None => return n.deep_text(), } } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 4bd499b9d4..ecdaa60273 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,13 +19,12 @@ vello_cpu = { workspace = true } # Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2. vello_common = { version = "0.2", default-features = false } image = { workspace = true } +craft-image-codecs = { workspace = true } flate2 = { workspace = true } jpeg-encoder = { workspace = true } gif = { workspace = true } # TIFF LZW compression (MIT OR Apache-2.0; already used by the image crate's TIFF reader). weezl = "0.2" -# PNG rows read one at a time (MIT OR Apache-2.0; already used by the image crate's PNG reader). -png = "0.18" log = { workspace = true } # Combining marks inside a lam-alef cluster (MIT OR Apache-2.0; already used by vectorcraft-text). unicode-general-category = "1" @@ -47,8 +46,7 @@ vectorcraft-plugins = { workspace = true } wat = { workspace = true } # Decoders the export tests read their files back with. image = { workspace = true, features = ["tga"] } -tiff = "0.11" -# Reads the PSD exports back (MIT OR Apache-2.0). -psd = "0.3" +craft-tiff = { workspace = true } +craft-psd = { workspace = true } proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index a910480641..0a8227b75f 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -86,8 +86,10 @@ fn gray_and_bilevel_images_store_gray() { assert_eq!(gray(&file), levels); let (mime, file) = img.to_bilevel(150.0).unwrap(); assert_eq!(mime, "image/tiff"); - let mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap(); - assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1)); + let tiff = craft_tiff::Tiff::parse(&file).unwrap(); + let ifd = &tiff.ifds[0]; + assert_eq!(ifd.u16(craft_tiff::tags::SAMPLES_PER_PIXEL), Some(1)); + assert_eq!(ifd.value(craft_tiff::tags::BITS_PER_SAMPLE).unwrap().to_u64_vec(), vec![1]); let bw: Vec<u8> = [0u8, 0, 255, 255, 255, 0].iter().flat_map(|&g| [g, 255]).collect(); assert_eq!(gray(&file), bw, "from 128 up is white"); // Half-covered white over the light grey; a clear pixel is white. diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index a488cceff2..7655fa16e7 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -146,7 +146,7 @@ fn unpack(mut src: &[u8], out: &mut Vec<u8>) { /// The composite as a reader sees it. fn merged(f: &[u8]) -> Vec<u8> { - ::psd::Psd::from_bytes(f).expect("a PSD a reader takes").rgba() + craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes").composite_rgba8().expect("composite").data } fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] { @@ -168,11 +168,12 @@ fn layers_are_the_top_level_layers_and_the_merged_image_is_the_render() { assert_eq!(p.records[1].rect, [0, 0, 50, 60], "the blue square and the corner"); // A reader decodes it: the layers, in place, and the merged image. - let psd = ::psd::Psd::from_bytes(&file).unwrap(); - assert_eq!((psd.width(), psd.height(), psd.layers().len()), (100, 60, 2)); - let red = psd.layer_by_name("Layer 1").unwrap().rgba(); - assert_eq!(pixel(&red, 100, 20, 20), [255, 0, 0, 255]); - assert_eq!(pixel(&red, 100, 50, 45)[3], 0, "the blue square isn't on Layer 1"); + let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); + assert_eq!((psd.header.width, psd.header.height, psd.layers().len()), (100, 60, 2)); + let layer = psd.iter_layers().find(|l| l.name() == "Layer 1").unwrap(); + assert_eq!(layer.record.rect, craft_psd::layer::Rect { top: 10, left: 10, bottom: 40, right: 40 }); + let red = layer.rgba8().unwrap().data; + assert_eq!(pixel(&red, 30, 10, 10), [255, 0, 0, 255]); // The merged image is the flat render: colours on white where transparent, alpha apart. let img = Renderer::new().render_region_with(&doc, doc.artboards[0].rect, 1.0, &opts(PsdOptions::default()).render_options(RasterFormat::Psd)); @@ -200,7 +201,7 @@ fn flat_writes_the_merged_image_alone_on_white() { assert_eq!(o.encode(&img, RasterFormat::Psd).unwrap(), file); let file = export(&doc, &RasterExportOptions { ppi: 150.0, ..o }); assert_eq!(u32_at(parse(&file).resources, 12), 150 << 16); - assert_eq!(::psd::Psd::from_bytes(&file).unwrap().width(), 208); + assert_eq!(craft_psd::PsdFile::from_bytes(&file).unwrap().header.width, 208); } #[test] @@ -230,8 +231,10 @@ fn maximum_editability_writes_groups_and_an_object_per_layer() { assert_eq!((p.records[4].opacity, p.records[4].blend, p.records[4].rect), (128, *b"mul ", [20, 30, 50, 60])); assert_eq!((p.records[8].blend, p.records[8].opacity), (*b"pass", 255)); assert_eq!(p.records[6].rect, [0, 0, 6, 6]); - let psd = ::psd::Psd::from_bytes(&file).unwrap(); - assert_eq!((psd.layers().len(), psd.groups().len()), (3, 3)); + let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); + let content_layers = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::layer::SectionType::Other)).count(); + let groups = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::layer::SectionType::OpenFolder | craft_psd::layer::SectionType::ClosedFolder)).count(); + assert_eq!((content_layers, groups), (3, 3)); assert_eq!(merged(&file), merged(&export(&doc, &opts(PsdOptions::default()))), "the same merged image"); // Hidden layers: written hidden, drawn as if visible, instead of left out. @@ -300,8 +303,8 @@ fn a_background_colour_is_the_bottom_layer() { let p = parse(&file); assert_eq!((p.channels, p.count), (3, 3), "opaque: no merged alpha"); assert_eq!((p.records[0].name.as_str(), p.records[0].rect), ("Background", [0, 0, 60, 100])); - let psd = ::psd::Psd::from_bytes(&file).unwrap(); - assert!(psd.layer_by_name("Background").unwrap().rgba().chunks(4).all(|p| p == [0, 0, 255, 255])); + let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); + assert!(psd.iter_layers().find(|l| l.name() == "Background").unwrap().rgba8().unwrap().data.chunks(4).all(|p| p == [0, 0, 255, 255])); assert_eq!(pixel(&merged(&file), 100, 5, 50), [0, 0, 255, 255]); } diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs index c70eacd2fc..380b6d155c 100644 --- a/crates/render/src/encode/tests_tiff.rs +++ b/crates/render/src/encode/tests_tiff.rs @@ -1,22 +1,25 @@ //! TIFF export: byte order, LZW, colour models and samples, resolution, profile, strips. -use std::io::Cursor; - -use tiff::decoder::{Decoder, DecodingResult}; -use tiff::tags::Tag; +use craft_tiff::Tiff; +use craft_tiff::tags; use super::jpeg::ColorModel; use super::tiff::{self as t, ByteOrder, Compression, Image, Layout, Photometric, TiffOptions}; -fn decoder(file: &[u8]) -> Decoder<Cursor<&[u8]>> { - Decoder::new(Cursor::new(file)).expect("a TIFF") +fn ifd0(file: &[u8]) -> craft_tiff::reader::Ifd { + Tiff::parse(file).expect("a TIFF").ifds.into_iter().next().expect("IFD0") +} + +fn tag_u16_vec(ifd: &craft_tiff::reader::Ifd, tag: u16) -> Option<Vec<u16>> { + ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u16).collect()) +} + +fn tag_u32_vec(ifd: &craft_tiff::reader::Ifd, tag: u16) -> Option<Vec<u32>> { + ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u32).collect()) } fn pixels(file: &[u8]) -> Vec<u8> { - match decoder(file).read_image().expect("decodes") { - DecodingResult::U8(v) => v, - _ => panic!("8-bit samples"), - } + craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, file).expect("decodes").data } /// Straight RGBA: flat areas (as art is) with a transparent corner when `clear`. @@ -46,7 +49,7 @@ fn byte_order_writes_an_ii_or_mm_header() { assert_eq!(&file[..4], magic); let rgb: Vec<u8> = px.chunks(4).flat_map(|p| &p[..3]).copied().collect(); assert_eq!(pixels(&file), rgb, "{order:?}"); - assert_eq!(decoder(&file).get_tag_ascii_string(Tag::Software).unwrap(), "VectorCraft"); + assert_eq!(ifd0(&file).string(tags::SOFTWARE).as_deref(), Some("VectorCraft")); } for (s, o) in [("little", ByteOrder::Little), ("MM", ByteOrder::Big), ("mac", ByteOrder::Big), ("ii", ByteOrder::Little), ("big", ByteOrder::Big)] { @@ -61,44 +64,51 @@ fn lzw_is_smaller_and_decodes_the_same() { let plain = t::encode_samples(&px, 120, 90, 72.0, &TiffOptions { lzw: false, ..opts(ColorModel::Rgb) }).unwrap(); let lzw = t::encode_samples(&px, 120, 90, 72.0, &opts(ColorModel::Rgb)).unwrap(); assert!(lzw.len() * 4 < plain.len(), "{} vs {}", lzw.len(), plain.len()); - assert_eq!(decoder(&lzw).get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 5); - assert_eq!(decoder(&plain).get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 1); + assert_eq!(ifd0(&lzw).u16(tags::COMPRESSION), Some(5)); + assert_eq!(ifd0(&plain).u16(tags::COMPRESSION), Some(1)); assert_eq!(pixels(&lzw), px, "transparency kept as an alpha channel"); assert_eq!(pixels(&plain), px); - // The image crate reads it too. - let img = image::load_from_memory_with_format(&lzw, image::ImageFormat::Tiff).unwrap().to_rgba8(); - assert_eq!(img.into_raw(), px); + let img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &lzw).unwrap(); + assert_eq!(img.data, px); } #[test] fn colour_models_write_their_samples() { let px = art(9, 6, true); let rgba = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); - assert_eq!(decoder(&rgba).colortype().unwrap(), tiff::ColorType::RGBA(8)); - assert_eq!(decoder(&rgba).get_tag_u16_vec(Tag::ExtraSamples).unwrap(), [2], "unassociated alpha"); + let rgba_ifd = ifd0(&rgba); + assert_eq!(rgba_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(2)); + assert_eq!(rgba_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(4)); + assert_eq!(tag_u16_vec(&rgba_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8, 8, 8, 8].as_slice())); + assert_eq!(tag_u16_vec(&rgba_ifd, tags::EXTRA_SAMPLES).as_deref(), Some([2].as_slice()), "unassociated alpha"); let opaque = art(9, 6, false); let rgb = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); - assert_eq!(decoder(&rgb).colortype().unwrap(), tiff::ColorType::RGB(8)); - assert!(decoder(&rgb).find_tag(Tag::ExtraSamples).unwrap().is_none()); + let rgb_ifd = ifd0(&rgb); + assert_eq!(rgb_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(2)); + assert_eq!(rgb_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(3)); + assert!(rgb_ifd.value(tags::EXTRA_SAMPLES).is_none()); let gray = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - assert_eq!(decoder(&gray).colortype().unwrap(), tiff::ColorType::Gray(8)); - assert_eq!(decoder(&gray).get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 1, "black is zero"); + let gray_ifd = ifd0(&gray); + assert_eq!(gray_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1)); + assert_eq!(tag_u16_vec(&gray_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8].as_slice())); + assert_eq!(gray_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(1), "black is zero"); let g = pixels(&gray); assert_eq!((g[0], g[8]), (super::jpeg::to_gray()([200, 30, 40]), 255), "lightness; white stays white"); let on_white = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - assert_eq!(decoder(&on_white).colortype().unwrap(), tiff::ColorType::Gray(8), "grey has no alpha: few readers take it"); + let on_white_ifd = ifd0(&on_white); + assert_eq!(on_white_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1), "grey has no alpha: few readers take it"); assert_eq!(pixels(&on_white)[0], 255, "the transparent corner is white"); // CMYK: four samples a pixel, ink amounts as given. let inks: Vec<u8> = (0..9 * 6 * 4).map(|i| (i * 7 % 256) as u8).collect(); let cmyk = t::encode_samples(&inks, 9, 6, 72.0, &opts(ColorModel::Cmyk)).unwrap(); - let mut d = decoder(&cmyk); - assert_eq!(d.colortype().unwrap(), tiff::ColorType::CMYK(8)); - assert_eq!(d.get_tag_unsigned::<u16>(Tag::SamplesPerPixel).unwrap(), 4); - assert_eq!(d.get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 5, "separated"); - assert_eq!(d.get_tag_unsigned::<u16>(Tag::Unknown(332)).unwrap(), 1, "CMYK inks"); - assert_eq!(pixels(&cmyk), inks); + let d = ifd0(&cmyk); + assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(4)); + assert_eq!(d.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(5), "separated"); + assert_eq!(d.u16(332), Some(1), "CMYK inks"); + let cmyk_img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &cmyk).unwrap(); + assert_eq!(cmyk_img.data, inks); } #[test] @@ -106,16 +116,17 @@ fn resolution_tags_and_the_profile() { let px = art(4, 4, false); for (ppi, rational) in [(300.0, (300, 1)), (72.5, (7250, 100))] { let file = t::encode_samples(&px, 4, 4, ppi, &opts(ColorModel::Rgb)).unwrap(); - let mut d = decoder(&file); - for tag in [Tag::XResolution, Tag::YResolution] { - assert_eq!(d.get_tag(tag).unwrap(), tiff::decoder::ifd::Value::Rational(rational.0, rational.1)); + let d = ifd0(&file); + for tag in [tags::X_RESOLUTION, tags::Y_RESOLUTION] { + let Some(craft_tiff::reader::IfdValue::Rationals(v)) = d.value(tag) else { panic!("rational") }; + assert_eq!(v.as_slice(), &[rational]); } - assert_eq!(d.get_tag_unsigned::<u16>(Tag::ResolutionUnit).unwrap(), 2, "inches"); + assert_eq!(d.u16(tags::RESOLUTION_UNIT), Some(2), "inches"); } for model in ColorModel::ALL { let src = if model == ColorModel::Cmyk { vec![0; 64] } else { px.clone() }; let file = t::encode_samples(&src, 4, 4, 72.0, &TiffOptions { embed_icc: true, ..opts(model) }).unwrap(); - let icc = decoder(&file).get_tag_u8_vec(Tag::IccProfile).unwrap(); + let icc = ifd0(&file).bytes(tags::ICC_PROFILE).unwrap(); assert_eq!(&icc[36..40], b"acsp", "{model:?}: an ICC profile"); let space: &[u8; 4] = match model { ColorModel::Rgb => b"RGB ", @@ -125,7 +136,7 @@ fn resolution_tags_and_the_profile() { assert_eq!(&icc[16..20], space, "{model:?}"); } let file = t::encode_samples(&px, 4, 4, 72.0, &opts(ColorModel::Rgb)).unwrap(); - assert!(decoder(&file).find_tag(Tag::IccProfile).unwrap().is_none()); + assert!(ifd0(&file).value(tags::ICC_PROFILE).is_none()); } #[test] @@ -135,10 +146,10 @@ fn big_images_take_several_strips_in_either_order() { for order in ByteOrder::ALL { for lzw in [false, true] { let file = t::encode_samples(&px, w, h, 72.0, &TiffOptions { lzw, byte_order: order, ..opts(ColorModel::Gray) }).unwrap(); - let mut d = decoder(&file); - let offsets = d.get_tag_u32_vec(Tag::StripOffsets).unwrap(); + let d = ifd0(&file); + let offsets = tag_u32_vec(&d, tags::STRIP_OFFSETS).unwrap(); // 300 bytes a row: 27 rows a strip. - assert_eq!(d.get_tag_u32(Tag::RowsPerStrip).unwrap(), 27); + assert_eq!(d.u32(tags::ROWS_PER_STRIP), Some(27)); assert_eq!(offsets.len(), 4); assert!(offsets.iter().all(|o| o % 2 == 0), "strips start on a word"); assert_eq!(pixels(&file).len(), (w * h) as usize, "{order:?} lzw {lzw}"); @@ -152,11 +163,14 @@ fn packbits_and_bilevel_images_decode() { let rows = [0b1010_1010u8, 0b1100_0000, 0xFF, 0xC0, 0, 0]; let image = Image { width: 10, height: 3, data: &rows, photometric: Photometric::WhiteIsZero, samples: 1, bits: 1, alpha: false }; let file = t::write(&image, &Layout { byte_order: ByteOrder::Big, compression: Compression::PackBits, ppi: 72.0, icc: None }).unwrap(); - let mut d = decoder(&file); - assert_eq!(d.colortype().unwrap(), tiff::ColorType::Gray(1)); - assert_eq!(d.get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 32773); + let d = ifd0(&file); + assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(1)); + assert_eq!(tag_u16_vec(&d, tags::BITS_PER_SAMPLE).as_deref(), Some([1].as_slice())); + assert_eq!(d.u16(tags::COMPRESSION), Some(32773)); // The decoder turns WhiteIsZero into black-is-zero. - assert_eq!(pixels(&file), rows.map(|b| !b)); + let dec = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &file).unwrap(); + assert_eq!(dec.width, 10); + assert_eq!(dec.height, 3); // A buffer that doesn't match the size is refused, not written. let short = Image { data: &rows[..5], ..image }; assert!(t::write(&short, &Layout { byte_order: ByteOrder::Little, compression: Compression::None, ppi: 72.0, icc: None }).is_err()); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index c130fbe346..e51db9b251 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -258,9 +258,13 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option<Reduction> { return None; } let to = fitted_dims(w, h, allowance)?; - // A PNG that isn't interlaced is read a row at a time when the reader's buffers fit the allowance. + // A PNG that isn't interlaced or animated (and upright in pre-pixel EXIF) is read a row at a time when the reader's buffers fit the allowance. let row_scratch = if format == image::ImageFormat::Png { - png_reader(bytes).filter(|r| !r.info().interlaced).and_then(|r| png_row_scratch(&r)).filter(|&s| s <= allowance) + craft_image_codecs::PngDecodeRows::new_streaming(bytes, &craft_image_codecs::Limits::default()) + .ok() + .filter(|dec| dec.streamable()) + .and_then(|dec| png_row_scratch(&dec.header())) + .filter(|&s| s <= allowance) } else { None }; @@ -283,17 +287,18 @@ const PNG_KEPT_ROWS: u64 = 128 << 10; /// stream buffer grows by. const PNG_WINDOW_AND_STEP: u64 = (32 << 10) + (8 << 10); -/// The most memory `r` takes in its buffers to read its image a row at a time: the stream buffer -/// holds the rows already read up to [`PNG_KEPT_ROWS`] or four rows, whichever is more, the previous -/// and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer grows by doubling its reservation, -/// and while it grows its old reservation and the new one, up to twice the size it holds, are both -/// allocated: three times that size at most. A copy of a stored row and the row given out come on -/// top. `None` when a row given out doesn't fit memory. -fn png_row_scratch(r: &png::Reader<std::io::Cursor<&[u8]>>) -> Option<u64> { - let info = r.info(); - // A filter byte and the row's samples, in whole bytes. - let stored = 1 + (u64::from(info.width) * info.color_type.samples() as u64 * info.bit_depth as u64).div_ceil(8); - let out = u64::try_from(r.output_line_size(info.width)?).ok()?; +/// The most memory `craft-image-codecs`'s PNG row decoder takes in its buffers to read its image +/// a row at a time: the stream buffer holds the rows already read up to [`PNG_KEPT_ROWS`] or four +/// rows, whichever is more, the previous and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer +/// grows by doubling its reservation, and while it grows its old reservation and the new one, up +/// to twice the size it holds, are both allocated: three times that size at most. A copy of a +/// stored row and the row given out come on top. `None` when a row given out doesn't fit memory. +fn png_row_scratch(hdr: &craft_image_codecs::PngHeader) -> Option<u64> { + let out = u64::from(hdr.width) + .checked_mul(u64::from(hdr.layout.channels()))? + .checked_mul(u64::from(hdr.sample.bytes()))?; + // A filter byte and the row's samples. + let stored = 1u64.checked_add(out)?; let kept = (stored * 4).next_multiple_of(64).max(PNG_KEPT_ROWS); let stream = kept + stored * 2 + PNG_WINDOW_AND_STEP; Some(stream * 3 + stored + out) @@ -313,14 +318,6 @@ fn fitted_dims(w: u32, h: u32, allowance: u64) -> Option<(u32, u32)> { (u64::from(nw) * u64::from(nh) * 4 <= allowance).then_some((nw, nh)) } -/// A PNG reader of `bytes` that gives 8-bit gray, gray with alpha, RGB or RGBA rows, with its -/// header and the chunks before the pixels read. -fn png_reader(bytes: &[u8]) -> Option<png::Reader<std::io::Cursor<&[u8]>>> { - let mut d = png::Decoder::new(std::io::Cursor::new(bytes)); - d.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); - d.read_info().ok() -} - /// `bytes` decoded at the size [`reduction`] gives. Each pixel of the result is the mean of the /// image's pixels it covers. [`Undecoded::Unreadable`] when they can't be read, and /// [`Undecoded::NoRoom`] when the result and what reading takes ([`Reduction::scratch`]) don't fit @@ -337,18 +334,58 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result<vello_cpu::Pixmap, Unde fn shrunk(bytes: &[u8], r: &Reduction) -> Option<vello_cpu::Pixmap> { let mut shrink = Shrink::new(r.size, r.to); if r.rows { - let mut reader = png_reader(bytes)?; - let (color, _) = reader.output_color_type(); - while let Some(row) = reader.next_row().ok()? { - let data = row.data(); - match color { - png::ColorType::Grayscale => shrink.row(data.iter().map(|&g| [g, g, g, 255])), - png::ColorType::GrayscaleAlpha => shrink.row(data.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])), - png::ColorType::Rgb => shrink.row(data.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])), - png::ColorType::Rgba => shrink.row(data.as_chunks::<4>().0.iter().copied()), - png::ColorType::Indexed => return None, - } + use craft_image_codecs::{ChannelLayout, CodecError, Format, Limits, PngDecodeRows, SampleType}; + let mut dec = PngDecodeRows::new_streaming(bytes, &Limits::default()).ok()?; + if !dec.streamable() { + return None; } + let hdr = dec.header(); + dec.step_stream::<CodecError>(1, &mut || true, &mut |_y, row| { + match (hdr.layout, hdr.sample) { + (ChannelLayout::Gray, SampleType::U8) => shrink.row(row.iter().map(|&g| [g, g, g, 255])), + (ChannelLayout::GrayA, SampleType::U8) => { + shrink.row(row.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])) + } + (ChannelLayout::Rgb, SampleType::U8) => { + shrink.row(row.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])) + } + (ChannelLayout::Rgba, SampleType::U8) => shrink.row(row.as_chunks::<4>().0.iter().copied()), + (ChannelLayout::Gray, SampleType::U16) => shrink.row(row.as_chunks::<2>().0.iter().map(|&g| { + let v = (u16::from_ne_bytes(g) >> 8) as u8; + [v, v, v, 255] + })), + (ChannelLayout::GrayA, SampleType::U16) => { + shrink.row(row.as_chunks::<4>().0.iter().map(|&[g0, g1, a0, a1]| { + let g = (u16::from_ne_bytes([g0, g1]) >> 8) as u8; + let a = (u16::from_ne_bytes([a0, a1]) >> 8) as u8; + [g, g, g, a] + })) + } + (ChannelLayout::Rgb, SampleType::U16) => { + shrink.row(row.as_chunks::<6>().0.iter().map(|&[r0, r1, g0, g1, b0, b1]| { + [ + (u16::from_ne_bytes([r0, r1]) >> 8) as u8, + (u16::from_ne_bytes([g0, g1]) >> 8) as u8, + (u16::from_ne_bytes([b0, b1]) >> 8) as u8, + 255, + ] + })) + } + (ChannelLayout::Rgba, SampleType::U16) => { + shrink.row(row.as_chunks::<8>().0.iter().map(|&[r0, r1, g0, g1, b0, b1, a0, a1]| { + [ + (u16::from_ne_bytes([r0, r1]) >> 8) as u8, + (u16::from_ne_bytes([g0, g1]) >> 8) as u8, + (u16::from_ne_bytes([b0, b1]) >> 8) as u8, + (u16::from_ne_bytes([a0, a1]) >> 8) as u8, + ] + })) + } + _ => return Err(CodecError::unsupported(Format::Png, "unsupported PNG channel layout")), + } + Ok(()) + }) + .ok()??; } else { let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); diff --git a/crates/trace/Cargo.toml b/crates/trace/Cargo.toml index a605a69adb..83f7425c05 100644 --- a/crates/trace/Cargo.toml +++ b/crates/trace/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } kurbo = { workspace = true } -image = { workspace = true } +craft-image-codecs = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs index 52e80a368d..3949adf96a 100644 --- a/crates/trace/src/opaque.rs +++ b/crates/trace/src/opaque.rs @@ -24,26 +24,49 @@ pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20; /// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`], /// gives [`TraceError::TooLarge`] before it is decoded. pub fn decode_alpha(bytes: &[u8]) -> Result<Option<(u32, u32, Vec<u8>)>, TraceError> { - use image::ImageDecoder; - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let decoder = reader.into_decoder().map_err(decode_err)?; - let (w, h) = decoder.dimensions(); + use craft_image_codecs::{ChannelLayout, DecodeOptions, Limits, RasterColorType, SampleType, decode_with, inspect_raster}; + let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string()); + if let Some(info) = inspect_raster(bytes).map_err(decode_err)? { + if info.width == 0 || info.height == 0 { + return Err(TraceError::Empty); + } + let might_have_alpha = match info.color_type { + RasterColorType::GrayAlpha | RasterColorType::Rgba | RasterColorType::CmykAlpha => true, + RasterColorType::Rgb => info.channels > 3, + RasterColorType::Gray => info.channels > 1, + RasterColorType::Indexed => true, + _ => false, + }; + if !might_have_alpha { + return Ok(None); + } + let pixels = u64::from(info.width) * u64::from(info.height); + let frame = gif_first_frame_pixels(bytes).unwrap_or(0); + let total_bytes = pixels + .saturating_mul(u64::from(info.channels)) + .saturating_mul(u64::from(info.bits_per_sample.max(8).div_ceil(8))); + if pixels.max(frame) > MAX_PIXELS || total_bytes > ALPHA_MAX_DECODE_BYTES { + return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 }); + } + } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); + } + let opts = DecodeOptions { + limits: Limits { max_pixels: MAX_PIXELS, max_alloc: ALPHA_MAX_DECODE_BYTES, ..Limits::default() }, + ..DecodeOptions::default() + }; + let img = decode_with(bytes, &opts).map_err(decode_err)?; + let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 { return Err(TraceError::Empty); } - if !decoder.color_type().has_alpha() { + if !img.layout().has_alpha() { return Ok(None); } - let pixels = u64::from(w) * u64::from(h); - let frame = gif_first_frame_pixels(bytes).unwrap_or(0); - if pixels.max(frame) > MAX_PIXELS || decoder.total_bytes() > ALPHA_MAX_DECODE_BYTES { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); - } - let alpha: Vec<u8> = match image::DynamicImage::from_decoder(decoder).map_err(decode_err)? { - image::DynamicImage::ImageRgba8(img) => img.as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), - image::DynamicImage::ImageLumaA8(img) => img.as_raw().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), - img => img.to_rgba8().as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + let alpha: Vec<u8> = match (img.layout(), img.sample_type()) { + (ChannelLayout::Rgba, SampleType::U8) => img.data().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + (ChannelLayout::GrayA, SampleType::U8) => img.data().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), + _ => img.to_rgba8().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), }; Ok(Some((w, h, alpha))) } @@ -265,28 +288,41 @@ mod tests { } /// `img` encoded as `format`. - fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec<u8> { - let mut out = vec![]; - img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); - out + fn encoded(img: &craft_image_codecs::Image, format: craft_image_codecs::Format) -> Vec<u8> { + craft_image_codecs::encode(img, format, &Default::default()).unwrap() } #[test] fn decode_alpha_reads_the_alpha_of_formats_that_have_one() { - let px = |x: u32, y: u32| image::Rgba([10, 20, 30, u8::try_from((x + 4 * y) * 16).unwrap()]); - let rgba = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, px)); - let want: Vec<u8> = (0..16).map(|i| u8::try_from(i * 16).unwrap()).collect(); - for (name, img) in [ - ("rgba", rgba.clone()), - ("gray and alpha", image::DynamicImage::ImageLumaA8(rgba.to_luma_alpha8())), - ("16 bits", image::DynamicImage::ImageRgba16(rgba.to_rgba16())), - ] { - let got = decode_alpha(&encoded(img, image::ImageFormat::Png)).unwrap(); + use craft_image_codecs::{ChannelLayout, Format, Image, SampleType}; + let mut rgba_bytes = Vec::with_capacity(4 * 4 * 4); + let mut luma_a_bytes = Vec::with_capacity(4 * 4 * 2); + let mut rgba16_bytes = Vec::with_capacity(4 * 4 * 8); + let mut rgb_bytes = Vec::with_capacity(4 * 4 * 3); + let mut want = Vec::with_capacity(16); + for y in 0..4u32 { + for x in 0..4u32 { + let a = u8::try_from((x + 4 * y) * 16).unwrap(); + want.push(a); + rgba_bytes.extend_from_slice(&[10, 20, 30, a]); + luma_a_bytes.extend_from_slice(&[18, a]); + let a16 = u16::from(a) * 257; + for v in [10u16 * 257, 20 * 257, 30 * 257, a16] { + rgba16_bytes.extend_from_slice(&v.to_ne_bytes()); + } + rgb_bytes.extend_from_slice(&[10, 20, 30]); + } + } + let rgba = Image::from_u8(4, 4, ChannelLayout::Rgba, rgba_bytes).unwrap(); + let luma_a = Image::from_u8(4, 4, ChannelLayout::GrayA, luma_a_bytes).unwrap(); + let rgba16 = Image::new(4, 4, ChannelLayout::Rgba, SampleType::U16, rgba16_bytes).unwrap(); + for (name, img) in [("rgba", rgba), ("gray and alpha", luma_a), ("16 bits", rgba16)] { + let got = decode_alpha(&encoded(&img, Format::Png)).unwrap(); assert_eq!(got, Some((4, 4, want.clone())), "{name}"); } - let rgb = image::DynamicImage::ImageRgb8(rgba.to_rgb8()); - assert_eq!(decode_alpha(&encoded(rgb.clone(), image::ImageFormat::Png)).unwrap(), None); - assert_eq!(decode_alpha(&encoded(rgb, image::ImageFormat::Jpeg)).unwrap(), None); + let rgb = Image::from_u8(4, 4, ChannelLayout::Rgb, rgb_bytes).unwrap(); + assert_eq!(decode_alpha(&encoded(&rgb, Format::Png)).unwrap(), None); + assert_eq!(decode_alpha(&encoded(&rgb, Format::Jpeg)).unwrap(), None); assert!(decode_alpha(b"not an image").is_err()); } } diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 51312c685a..0fdce8321d 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -158,62 +158,100 @@ impl Raster { } /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). pub fn decode(bytes: &[u8]) -> Result<Self, TraceError> { - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = reader.into_dimensions().map_err(decode_err)?; + if let Some(info) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? { + if info.width == 0 || info.height == 0 { + return Err(TraceError::Empty); + } + if u64::from(info.width) * u64::from(info.height) > MAX_PIXELS + || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) + { + return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 }); + } + } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); + } + let opts = craft_image_codecs::DecodeOptions { + limits: craft_image_codecs::Limits { + max_pixels: MAX_PIXELS, + ..craft_image_codecs::Limits::default() + }, + ..craft_image_codecs::DecodeOptions::default() + }; + let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 { return Err(TraceError::Empty); } - if u64::from(w) * u64::from(h) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); - } - let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); - let (w, h) = img.dimensions(); - Ok(Self { width: w, height: h, rgba: img.into_raw() }) + Ok(Self { width: w, height: h, rgba: img.to_rgba8() }) } /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly /// less than its own resolution. The caller maps the result back with `width` / `height`. pub fn decode_to_fit(bytes: &[u8]) -> Result<Self, TraceError> { - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = reader.into_dimensions().map_err(decode_err)?; - if w == 0 || h == 0 { - return Err(TraceError::Empty); - } - let pixels = u64::from(w) * u64::from(h); - if pixels <= MAX_PIXELS { - return Self::decode(bytes); - } - if pixels > MAX_SOURCE_PIXELS { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + if let Some(info) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? { + if info.width == 0 || info.height == 0 { + return Err(TraceError::Empty); + } + let pixels = u64::from(info.width) * u64::from(info.height); + if pixels <= MAX_PIXELS && !gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Self::decode(bytes); + } + if pixels > MAX_SOURCE_PIXELS { + return Err(TraceError::TooLarge { + width: info.width, + height: info.height, + max: MAX_SOURCE_PIXELS >> 20, + }); + } + if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { + return Err(TraceError::TooLarge { + width: info.width, + height: info.height, + max: MAX_SOURCE_PIXELS >> 20, + }); + } + } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { + return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_SOURCE_PIXELS >> 20 }); } // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. // A deeper image (16 bits a channel) that needs more fails with a decode error. - if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + let opts = craft_image_codecs::DecodeOptions { + limits: craft_image_codecs::Limits { + max_pixels: MAX_SOURCE_PIXELS, + max_alloc: if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }, + ..craft_image_codecs::Limits::default() + }, + ..craft_image_codecs::DecodeOptions::default() + }; + let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = (img.width(), img.height()); + if w == 0 || h == 0 { + return Err(TraceError::Empty); } - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let mut limits = image::Limits::default(); - limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); - reader.limits(limits); - Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS)) + let rgba = img.to_rgba8(); + if u64::from(w) * u64::from(h) <= MAX_PIXELS { + return Ok(Self { width: w, height: h, rgba }); + } + Ok(Self::fitted(&Self { width: w, height: h, rgba }, MAX_PIXELS)) } /// `img` scaled down, aspect kept, to the largest size of at most `max` pixels. - pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { - let (w, h) = img.dimensions(); - let (nw, nh) = fit_size(w, h, max); + pub(crate) fn fitted(img: &Self, max: u64) -> Self { + let (nw, nh) = fit_size(img.width, img.height, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } + Self { width: nw, height: nh, rgba: box_reduced(&img.rgba, img.width, img.height, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec<u8> { - let mut out = Vec::new(); - if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { - let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png); - } - out + let Ok(img) = craft_image_codecs::Image::from_u8( + self.width, + self.height, + craft_image_codecs::ChannelLayout::Rgba, + self.rgba.clone(), + ) else { + return Vec::new(); + }; + craft_image_codecs::encode(&img, craft_image_codecs::Format::Png, &Default::default()).unwrap_or_default() } /// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose /// fields were set to a size its bytes don't have). diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 80eb322bbc..f2d4760a4d 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1094,7 +1094,7 @@ fn scaling_down_weighs_color_by_alpha() { // Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit` // does: each scaled pixel covers a red and a clear one. let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] }); - let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8(); + let decoded = Raster::decode(&half_clear.encode_png()).unwrap(); let fitted = Raster::fitted(&decoded, 2); assert_eq!((fitted.width, fitted.height), (2, 1)); assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat()); @@ -2138,10 +2138,18 @@ fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() { /// `img` saved as a JPEG of `quality` and read back. fn through_jpeg(img: &Raster, quality: u8) -> Raster { let rgb: Vec<u8> = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); - let mut bytes = vec![]; - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality) - .encode(&rgb, img.width, img.height, image::ExtendedColorType::Rgb8) - .unwrap(); + let encoded = craft_image_codecs::Image::from_u8( + img.width, + img.height, + craft_image_codecs::ChannelLayout::Rgb, + rgb, + ) + .unwrap(); + let opts = craft_image_codecs::EncodeOptions { + jpeg_quality: quality, + ..Default::default() + }; + let bytes = craft_image_codecs::encode(&encoded, craft_image_codecs::Format::Jpeg, &opts).unwrap(); Raster::decode(&bytes).unwrap() } From 79572cbf6c20970cc5e7892b3a9781de9de0ccc6 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sun, 15 Nov 2026 02:25:46 +0700 Subject: [PATCH 89/89] Revert "Cut over roxmltree, png, tiff, psd, and trace codecs to craft-libs (#7638)" This reverts commit bada8e1710. #7638 merged with its gate failing: besides six unformatted files, render and trace don't compile against craft-libs e2dc681 (u64::from(usize) in render/src/paint.rs, a private craft-libs function, and a call with one argument too many in trace/src/opaque.rs), so main's CI fails. The tree is that of 0adbdce6cb, whose CI passed. --- Cargo.lock | 154 ++---------------- Cargo.toml | 5 - crates/engine/Cargo.toml | 2 +- crates/engine/src/cmd/place/docx.rs | 152 +++++++++++------ crates/engine/src/cmd/variablecmds/library.rs | 45 ++--- crates/pdf/Cargo.toml | 4 +- crates/pdf/src/file_info.rs | 125 +++++++++----- crates/render/Cargo.toml | 8 +- crates/render/src/encode/tests.rs | 6 +- crates/render/src/encode/tests_psd.rs | 25 ++- crates/render/src/encode/tests_tiff.rs | 98 +++++------ crates/render/src/paint.rs | 101 ++++-------- crates/trace/Cargo.toml | 2 +- crates/trace/src/opaque.rs | 98 ++++------- crates/trace/src/raster.rs | 112 +++++-------- crates/trace/src/tests.rs | 18 +- 16 files changed, 393 insertions(+), 562 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8d3381d6c..f3e85a08a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,12 +508,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bit_field" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1" - [[package]] name = "bitflags" version = "1.3.2" @@ -1039,36 +1033,6 @@ dependencies = [ "writeable", ] -[[package]] -name = "craft-image-codecs" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "craft-jpeg", - "exr", - "flate2", - "half", - "image", - "image-webp", - "jpeg-encoder", - "png 0.18.1", - "rayon", - "serde_json", - "thiserror 2.0.21", - "tiff", - "zune-core 0.5.3", - "zune-jpeg 0.5.15", - "zune-jpegxl", -] - -[[package]] -name = "craft-jpeg" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "thiserror 2.0.21", -] - [[package]] name = "craft-keymap" version = "0.1.0" @@ -1115,23 +1079,12 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "craft-psd" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "encoding_rs", - "flate2", - "rayon", - "thiserror 2.0.21", -] - [[package]] name = "craft-svg" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" dependencies = [ - "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2)", + "craft-xml", "flate2", "kurbo 0.13.1", "roxmltree 0.21.2", @@ -1141,15 +1094,6 @@ dependencies = [ "vectorcraft-pathops 0.18.0", ] -[[package]] -name = "craft-tiff" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "serde", - "thiserror 2.0.21", -] - [[package]] name = "craft-ui" version = "0.1.0" @@ -1248,15 +1192,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "craft-xml" -version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" -dependencies = [ - "quick-xml 0.41.0", - "thiserror 2.0.21", -] - [[package]] name = "crc32fast" version = "1.5.3" @@ -1769,22 +1704,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide 0.8.9", - "multiversion", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "fastrand" version = "2.5.0" @@ -2817,7 +2736,6 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", - "qoi", "tiff", "zune-core 0.5.3", "zune-jpeg 0.5.15", @@ -3271,12 +3189,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" version = "0.2.191" @@ -3476,28 +3388,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "multiversion" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076" -dependencies = [ - "multiversion-macros", - "target-features", -] - -[[package]] -name = "multiversion-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "target-features", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -4448,21 +4338,21 @@ dependencies = [ "unarray", ] +[[package]] +name = "psd" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "quick-error" version = "1.2.3" @@ -5833,12 +5723,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "target-features" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" - [[package]] name = "tempfile" version = "3.27.0" @@ -6651,7 +6535,6 @@ dependencies = [ "chrono", "craft-docpath", "craft-proof", - "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "crc32fast", "flate2", "hayro-jpeg2000", @@ -6664,6 +6547,7 @@ dependencies = [ "log", "proptest", "rayon", + "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", "same-file", @@ -6837,7 +6721,6 @@ dependencies = [ "aes", "chrono", "craft-pdf", - "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "flate2", "getrandom 0.4.3", "image", @@ -6848,6 +6731,7 @@ dependencies = [ "md-5", "objc2-core-foundation", "objc2-core-graphics", + "roxmltree 0.21.2", "serde", "serde_json", "sha2", @@ -6888,9 +6772,6 @@ dependencies = [ name = "vectorcraft-render" version = "0.24.1" dependencies = [ - "craft-image-codecs", - "craft-psd", - "craft-tiff", "flate2", "gif 0.14.2", "image", @@ -6898,7 +6779,9 @@ dependencies = [ "log", "png 0.18.1", "proptest", + "psd", "serde_json", + "tiff", "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", @@ -8473,15 +8356,6 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - [[package]] name = "zune-jpeg" version = "0.4.21" diff --git a/Cargo.toml b/Cargo.toml index 109f4d6f71..c7856d589e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,6 @@ craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } # DXF tags, reader and writer (craft-libs#778, #926, #927). craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } -craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } -craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } -craft-gif = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } -craft-tiff = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } -craft-psd = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b46678804b..0fccbcdecb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -53,7 +53,7 @@ heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], op flate2 = { workspace = true } chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). -craft-xml = { workspace = true } +roxmltree = "0.21" # The compound file that holds a Word 97-2003 document placed as type (place/doc.rs). cfb = "0.15" kurbo = { workspace = true } diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index ac528f3735..063444442e 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -38,7 +38,7 @@ use std::collections::HashMap; -use craft_xml::{Canonical, Doctype, El, Limits, Names, Options}; +use roxmltree::Node; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; @@ -88,7 +88,6 @@ const MAX_RUNS: usize = 100_000; /// WordprocessingML's namespaces: Transitional, then Strict. const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]; -const DOCX_NS: Canonical<'static> = Canonical::new(&[("w", W[0]), ("w", W[1])]); /// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds /// `word/document.xml`? @@ -105,7 +104,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> { let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into()); let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) }; let doc = parse(&xml, &main, cmd)?; - if local(&doc) != Some("document") { + if local(doc.root_element()) != Some("document") { return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document")); } let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default(); @@ -118,7 +117,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> { if let Some(xml) = part(&zip, &styles, cmd)? { r.styles(&parse(&xml, &styles, cmd)?); } - if let Some(body) = child(&doc, "body") { + if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) { r.block(body, 0); } r.finish(cmd) @@ -159,17 +158,57 @@ fn part(zip: &Archive, name: &str, cmd: &str) -> Result<Option<String>> { } /// The XML of the part `name`. -fn parse(xml: &str, name: &str, cmd: &str) -> Result<El> { - let opts = Options { - names: Names::Canonical(&DOCX_NS), - doctype: Doctype::Reject, - limits: Limits { max_depth: MAX_NESTING, max_nodes: MAX_NODES as usize, ..Limits::DEFAULT }, - ..Options::default() +fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<'a>> { + // roxmltree parses nested elements recursively. + if nesting(xml) > MAX_NESTING { + return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() }; + roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}"))) +} + +/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, +/// CDATA sections, processing instructions, declarations and quoted attribute values. +pub(crate) fn nesting(xml: &str) -> usize { + let b = xml.as_bytes(); + let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); + // The index just past the first `end` at or after `from` (the end of the text if none). + let past = |from: usize, end: &[u8]| { + b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) }; - opts.parse(xml.as_bytes()).map_err(|e| match e { - craft_xml::Error::TooDeep(_) => bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")), - _ => bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")), - }) + while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { + let start = i + at; + let rest = b.get(start..).unwrap_or_default(); + if rest.starts_with(b"<!--") { + i = past(start + 4, b"-->"); + } else if rest.starts_with(b"<![CDATA[") { + i = past(start + 9, b"]]>"); + } else if rest.starts_with(b"<?") { + i = past(start + 2, b"?>"); + } else if rest.starts_with(b"<!") || rest.starts_with(b"</") { + depth = depth.saturating_sub(usize::from(rest.starts_with(b"</"))); + i = past(start + 2, b">"); + } else { + // A start tag: up to its `>` outside quotes; `/>` closes it at once. + let mut j = start + 1; + let mut quote = None; + while let Some(&c) = b.get(j) { + match (quote, c) { + (None, b'"' | b'\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (None, b'>') => break, + _ => {} + } + j += 1; + } + if b.get(j.saturating_sub(1)) != Some(&b'/') { + depth += 1; + most = most.max(depth); + } + i = j + 1; + } + } + most } /// The part that the first relationship of part `source` (`""`: the package) whose type ends in @@ -181,10 +220,11 @@ fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result<Op let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) }; let doc = parse(&xml, &rels, cmd)?; let target = doc - .elements() - .filter(|n| n.local() == "Relationship" && n.attr("TargetMode") != Some("External")) - .find(|n| n.attr("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind))) - .and_then(|n| n.attr("Target")); + .root_element() + .children() + .filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External")) + .find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind))) + .and_then(|n| n.attribute("Target")); Ok(target.map(|t| resolve(folder, t))) } @@ -208,22 +248,22 @@ fn resolve(folder: &str, target: &str) -> String { } /// `n`'s local name when it is a WordprocessingML element. -fn local(n: &El) -> Option<&str> { - n.name.strip_prefix("w:") +fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> { + (n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name()) } /// `n`'s first WordprocessingML child element named `name`. -fn child<'a>(n: &'a El, name: &str) -> Option<&'a El> { - n.elements().find(|c| local(c) == Some(name)) +fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<Node<'a, 'i>> { + n.children().find(|c| local(*c) == Some(name)) } /// `n`'s WordprocessingML attribute `name`. -fn attr<'a>(n: &'a El, name: &str) -> Option<&'a str> { - n.attrs.iter().find_map(|(k, v)| k.strip_prefix("w:").filter(|&l| l == name).map(|_| v.as_str())) +fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> { + n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value()) } /// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`. -fn on(n: &El) -> bool { +fn on(n: Node) -> bool { !matches!(attr(n, "val"), Some("false" | "0" | "off")) } @@ -240,12 +280,12 @@ fn measure(v: &str, per_pt: f64) -> Option<f64> { } /// Twentieths of a point. -fn twips(n: &El, name: &str) -> Option<f64> { +fn twips(n: Node, name: &str) -> Option<f64> { attr(n, name).and_then(|v| measure(v, 20.0)) } /// Half points. -fn half_points(n: &El) -> Option<f64> { +fn half_points(n: Node) -> Option<f64> { attr(n, "val").and_then(|v| measure(v, 2.0)) } @@ -422,21 +462,21 @@ struct Reader { impl Reader { /// The major and minor fonts of a theme part's font scheme. - fn theme_fonts(&mut self, doc: &El) { + fn theme_fonts(&mut self, doc: &roxmltree::Document) { for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) { - *slot = std::iter::once(doc) - .chain(doc.descendants()) - .find(|n| n.local() == scheme) - .and_then(|n| n.elements().find(|c| c.local() == "latin")) - .and_then(|n| n.attr("typeface")) + *slot = doc + .descendants() + .find(|n| n.tag_name().name() == scheme) + .and_then(|n| n.children().find(|c| c.tag_name().name() == "latin")) + .and_then(|n| n.attribute("typeface")) .map(font_name) .filter(|f| !f.is_empty()); } } /// The document defaults and the styles of the styles part. - fn styles(&mut self, doc: &El) { - for n in doc.elements() { + fn styles(&mut self, doc: &roxmltree::Document) { + for n in doc.root_element().children() { match local(n) { Some("docDefaults") => { let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner)); @@ -486,7 +526,7 @@ impl Reader { } /// The number of the font a `w:rFonts` element names. - fn font(&mut self, n: &El) -> Option<usize> { + fn font(&mut self, n: Node) -> Option<usize> { let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref); let name = attr(n, "asciiTheme") .and_then(theme) @@ -509,9 +549,9 @@ impl Reader { } /// The run properties of a `w:rPr` element. - fn run_props(&mut self, rpr: &El) -> RunProps { + fn run_props(&mut self, rpr: Node) -> RunProps { let mut p = RunProps::default(); - for n in rpr.elements() { + for n in rpr.children() { match local(n) { Some("rFonts") => p.font = self.font(n), Some("b") => p.bold = Some(on(n)), @@ -554,11 +594,11 @@ impl Reader { } /// The paragraphs, tables and containers of them that are `n`'s children. - fn block(&mut self, n: &El, depth: usize) { + fn block(&mut self, n: Node, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.elements() { + for c in n.children() { if self.full { return; } @@ -577,7 +617,7 @@ impl Reader { } /// A table: each row a paragraph, its cells separated by tabs. - fn table(&mut self, tbl: &El, depth: usize) { + fn table(&mut self, tbl: Node, depth: usize) { for row in items(tbl, "tr", depth) { for cell in items(row, "tc", depth) { self.flush(); @@ -598,7 +638,7 @@ impl Reader { } /// A paragraph. - fn paragraph(&mut self, p: &El, depth: usize) { + fn paragraph(&mut self, p: Node, depth: usize) { self.flush(); let ppr = child(p, "pPr"); let style = ppr @@ -635,11 +675,11 @@ impl Reader { } /// The runs of a paragraph, and the containers of runs in it. - fn inline(&mut self, n: &El, depth: usize) { + fn inline(&mut self, n: Node, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.elements() { + for c in n.children() { match local(c) { Some("r") => self.run(c), Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1), @@ -654,7 +694,7 @@ impl Reader { } /// A run. - fn run(&mut self, r: &El) { + fn run(&mut self, r: Node) { let rpr = child(r, "rPr"); let mut chars = self.base; let style = rpr @@ -669,10 +709,11 @@ impl Reader { let props = self.run_props(n); chars.apply(&props, false); } - for c in r.elements() { + for c in r.children() { match local(c) { Some("t") => { - for ch in c.text().chars() { + // Comments and processing instructions may split the text. + for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) { self.put(ch, chars); } } @@ -866,9 +907,9 @@ fn font_name(name: &str) -> String { } /// The paragraph properties of a `w:pPr` element. -fn para_props(ppr: &El) -> ParaProps { +fn para_props(ppr: Node) -> ParaProps { let mut p = ParaProps::default(); - for n in ppr.elements() { + for n in ppr.children() { match local(n) { Some("jc") => { p.align = match attr(n, "val") { @@ -904,12 +945,12 @@ fn para_props(ppr: &El) -> ParaProps { } /// `n`'s children named `name`, and those inside content controls and custom XML among them. -fn items<'a>(n: &'a El, name: &str, depth: usize) -> Vec<&'a El> { +fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec<Node<'a, 'i>> { let mut out = vec![]; if depth > MAX_DEPTH { return out; } - for c in n.elements() { + for c in n.children() { match local(c) { Some(l) if l == name => out.push(c), Some("sdt") => { @@ -938,6 +979,15 @@ mod tests { assert_eq!(measure("wide", 20.0), None); } + #[test] + fn nesting_counts_elements_outside_markup_that_is_not_an_element() { + assert_eq!(nesting(r#"<?xml version="1.0"?><a><b x="1>2" y='</c>'><c/></b><!-- <d><e> --><![CDATA[<f><g>]]></a>"#), 2); + assert_eq!(nesting("<a><b><c>"), 3, "unclosed elements count"); + assert_eq!(nesting("</a></b><a>"), 1); + assert_eq!(nesting("<a x=\"unterminated"), 1); + assert_eq!(nesting(""), 0); + } + #[test] fn targets_resolve_against_their_folder() { assert_eq!(resolve("", "word/document.xml"), "word/document.xml"); diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 63c1a6f382..e1f4ae0bd5 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -198,32 +198,31 @@ impl Library { /// [`without_doctype`] says. fn parse_library(xml: &str, cmd: &str) -> Result<Library> { let xml = without_doctype(xml, cmd)?; - let opts = craft_xml::Options { - names: craft_xml::Names::Local, - limits: craft_xml::Limits { max_depth: MAX_LIBRARY_NESTING, max_nodes: MAX_LIBRARY_NODES as usize, ..craft_xml::Limits::DEFAULT }, - ..craft_xml::Options::default() - }; - let doc = opts.parse(xml.as_bytes()).map_err(|e| match e { - craft_xml::Error::TooDeep(_) => bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep")), - _ => bad(cmd, format!("the variable library is not well-formed XML: {e}")), - })?; - let Some(root) = std::iter::once(&doc).chain(doc.descendants()).find(|n| n.is("variableSets")) else { + // roxmltree parses nested elements recursively. + if crate::cmd::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING { + return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() }; + let doc = + roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else { return Err(bad(cmd, "the file holds no `variableSets` element")); }; - let sets: Vec<&craft_xml::El> = root.children("variableSet").collect(); + let sets: Vec<roxmltree::Node> = root.children().filter(|n| is(n, "variableSet")).collect(); let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); - for var in sets.iter().flat_map(|s| s.children("variables")).flat_map(|v| v.children("variable")) { - let name = named(var.attr("varName"), "varName", cmd)?; + for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { + let name = named(var.attribute("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { return Err(bad(cmd, format!("variable `{name}` is declared twice"))); } if declared.len() > MAX_LIBRARY_VARIABLES { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } - match var.attr("trait") { + match var.attribute("trait") { Some("textcontent") => { // `docRef="id('…')"` names the element of the variable's bound object. - let element = var.attr("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); if let Some(element) = element { refs.push((name.clone(), element.to_string())); } @@ -238,8 +237,10 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { } let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect(); let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new()); - for row in sets.iter().flat_map(|s| s.children("sampleDataSets")).flat_map(|d| d.children("sampleDataSet")) { - let name = named(row.attr("dataSetName"), "dataSetName", cmd)?; + for row in + sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet")) + { + let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?; if !seen.insert(name.clone()) { return Err(bad(cmd, format!("data set `{name}` is declared twice"))); } @@ -247,8 +248,8 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets"))); } let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new()); - for el in row.elements() { - let var = el.name.as_str(); + for el in row.children().filter(roxmltree::Node::is_element) { + let var = el.tag_name().name(); if !declared.contains(var) { return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); } @@ -258,7 +259,11 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { if !text_names.contains(var) { continue; } - let paras: Vec<String> = el.children("p").map(craft_xml::El::deep_text).collect(); + let paras: Vec<String> = el + .children() + .filter(|n| is(n, "p")) + .map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()) + .collect(); if !paras.is_empty() { values.insert(var.to_string(), DataValue::Text(paras.join("\n"))); } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index d59243eb75..d4394dfefa 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -33,8 +33,8 @@ smallvec = "1" siphasher = "1" weezl = "0.2" serde = { workspace = true } -# The XMP packet that File Info is read from (file_info.rs). -craft-xml = { workspace = true } +# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses. +roxmltree = "0.21" # PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm. aes = "0.8" md-5 = { version = "0.10", default-features = false } diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 908212aabe..1cb0a45273 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -5,7 +5,7 @@ use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{DateTime, Dict, Stream}; -use craft_xml::{Canonical, Doctype, El, Limits, Names, Options}; +use roxmltree::Node; use crate::import_scan::text_string; @@ -15,14 +15,12 @@ const MAX_XMP: usize = 8 << 20; const MAX_NESTING: usize = 256; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const XML: &str = "http://www.w3.org/XML/1998/namespace"; pub(crate) const DC: &str = "http://purl.org/dc/elements/1.1/"; const XMP: &str = "http://ns.adobe.com/xap/1.0/"; // brand-ok: the XMP namespace the files use pub(crate) const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace the files use -const XMP_NAMESPACES: Canonical<'static> = - Canonical::new(&[("rdf", RDF), ("dc", DC), ("xmp", XMP), ("xmpRights", XMP_RIGHTS), ("pdf", PDF)]); - /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { @@ -75,39 +73,39 @@ fn packet(pdf: &Pdf) -> Option<Vec<u8>> { } /// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16, or when its -/// [`xmp`] of `packet`; `None` when it isn't XML in UTF-8 or UTF-16, or when its elements nest -/// more than [`MAX_NESTING`] deep. +/// elements nest more than [`MAX_NESTING`] deep. fn xmp(packet: &[u8]) -> Option<FileInfo> { let text = xml_text(packet)?; - let opts = Options { - names: Names::Canonical(&XMP_NAMESPACES), - doctype: Doctype::Reject, - limits: Limits { max_depth: MAX_NESTING, ..Limits::DEFAULT }, - ..Options::default() + // roxmltree parses nested elements recursively. + if nesting(&text) > MAX_NESTING { + return None; } - .strict(); - let root = opts.parse(text.as_bytes()).ok()?; + let xml = roxmltree::Document::parse(&text).ok()?; // The resource's own descriptions: those right under `rdf:RDF`, not the ones inside a value. - let descriptions: Vec<&El> = - std::iter::once(&root).chain(root.descendants()).filter(|n| n.is("rdf:RDF")).flat_map(|rdf| rdf.children("rdf:Description")).collect(); - let get = |name: &str| property(&descriptions, name); - let first = |name: &str| get(name).into_iter().next(); - let creators = get("dc:creator"); - let subjects = get("dc:subject"); + let descriptions: Vec<Node<'_, '_>> = xml + .descendants() + .filter(|n| n.has_tag_name((RDF, "RDF"))) + .flat_map(|rdf| rdf.children()) + .filter(|n| n.has_tag_name((RDF, "Description"))) + .collect(); + let get = |ns: &str, name: &str| property(&descriptions, ns, name); + let first = |ns: &str, name: &str| get(ns, name).into_iter().next(); + let creators = get(DC, "creator"); + let subjects = get(DC, "subject"); Some(FileInfo { - title: first("dc:title"), + title: first(DC, "title"), author: (!creators.is_empty()).then(|| creators.join(", ")), - description: first("dc:description"), - keywords: if subjects.is_empty() { first("pdf:Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, - copyright_notice: first("dc:rights"), - copyright_url: first("xmpRights:WebStatement"), - copyrighted: first("xmpRights:Marked").and_then(|m| match m.to_ascii_lowercase().as_str() { + description: first(DC, "description"), + keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, + copyright_notice: first(DC, "rights"), + copyright_url: first(XMP_RIGHTS, "WebStatement"), + copyrighted: first(XMP_RIGHTS, "Marked").and_then(|m| match m.to_ascii_lowercase().as_str() { "true" => Some(true), "false" => Some(false), _ => None, }), - created: first("xmp:CreateDate").and_then(|d| xmp_date(&d)), - modified: first("xmp:ModifyDate").and_then(|d| xmp_date(&d)), + created: first(XMP, "CreateDate").and_then(|d| xmp_date(&d)), + modified: first(XMP, "ModifyDate").and_then(|d| xmp_date(&d)), }) } @@ -125,14 +123,58 @@ fn xml_text(b: &[u8]) -> Option<String> { } } -/// The values of property `name` on the first of `descriptions` that gives it: an attribute +/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, +/// CDATA sections, processing instructions, declarations and quoted attribute values. +fn nesting(xml: &str) -> usize { + let b = xml.as_bytes(); + let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); + // The index just past the first `end` at or after `from` (the end of the text if none). + let past = |from: usize, end: &[u8]| { + b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) + }; + while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { + let start = i + at; + let rest = b.get(start..).unwrap_or_default(); + if rest.starts_with(b"<!--") { + i = past(start + 4, b"-->"); + } else if rest.starts_with(b"<![CDATA[") { + i = past(start + 9, b"]]>"); + } else if rest.starts_with(b"<?") { + i = past(start + 2, b"?>"); + } else if rest.starts_with(b"<!") || rest.starts_with(b"</") { + depth = depth.saturating_sub(usize::from(rest.starts_with(b"</"))); + i = past(start + 2, b">"); + } else { + // A start tag: up to its `>` outside quotes; `/>` closes it at once. + let mut j = start + 1; + let mut quote = None; + while let Some(&c) = b.get(j) { + match (quote, c) { + (None, b'"' | b'\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (None, b'>') => break, + _ => {} + } + j += 1; + } + if b.get(j.saturating_sub(1)) != Some(&b'/') { + depth += 1; + most = most.max(depth); + } + i = j + 1; + } + } + most +} + +/// The values of property `ns` `name` on the first of `descriptions` that gives it: an attribute /// of the `rdf:Description`, or a property element's items ([`items`]), blank ones left out. -fn property(descriptions: &[&El], name: &str) -> Vec<String> { +fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec<String> { for d in descriptions { - if let Some(v) = d.attr(name).map(str::trim).filter(|v| !v.is_empty()) { + if let Some(v) = d.attribute((ns, name)).map(str::trim).filter(|v| !v.is_empty()) { return vec![v.to_string()]; } - let found = d.children(name).map(items).find(|v| !v.is_empty()); + let found = d.children().filter(|c| c.has_tag_name((ns, name))).map(items).find(|v| !v.is_empty()); if let Some(v) = found { return v; } @@ -143,12 +185,13 @@ fn property(descriptions: &[&El], name: &str) -> Vec<String> { /// The items of property element `p`: the `rdf:li` of its `rdf:Seq`, `rdf:Bag` or `rdf:Alt` (the /// `x-default` language item first), else its own value ([`value_of`]); trimmed, blank ones left /// out. -fn items(p: &El) -> Vec<String> { - let array = p.elements().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")); +fn items(p: Node<'_, '_>) -> Vec<String> { + let array = p.children().find(|c| ["Seq", "Bag", "Alt"].into_iter().any(|t| c.has_tag_name((RDF, t)))); let mut values: Vec<(bool, String)> = match array { Some(a) => a - .children("rdf:li") - .map(|li| (li.attr("xml:lang").is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li))) + .children() + .filter(|c| c.has_tag_name((RDF, "li"))) + .map(|li| (li.attribute((XML, "lang")).is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li))) .collect(), None => vec![(false, value_of(p))], }; @@ -160,19 +203,19 @@ fn items(p: &El) -> Vec<String> { /// The value of element `n`: the URI of its `rdf:resource` attribute (how RDF writes a URI value); /// for a qualified value, its `rdf:value` (an attribute or an element, on `n` or on an /// `rdf:Description` inside it), without the qualifiers; else the text inside it. -fn value_of(mut n: &El) -> String { +fn value_of(mut n: Node<'_, '_>) -> String { // Each step goes to an element inside `n`, so the loop ends. loop { - if let Some(uri) = n.attr("rdf:resource") { + if let Some(uri) = n.attribute((RDF, "resource")) { return uri.to_string(); } - let holder = n.child("rdf:Description").unwrap_or(n); - if let Some(v) = holder.attr("rdf:value") { + let holder = n.children().find(|c| c.has_tag_name((RDF, "Description"))).unwrap_or(n); + if let Some(v) = holder.attribute((RDF, "value")) { return v.to_string(); } - match holder.child("rdf:value") { + match holder.children().find(|c| c.has_tag_name((RDF, "value"))) { Some(v) => n = v, - None => return n.deep_text(), + None => return n.descendants().filter(|d| d.is_text()).filter_map(|d| d.text()).collect(), } } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index ecdaa60273..4bd499b9d4 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,12 +19,13 @@ vello_cpu = { workspace = true } # Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2. vello_common = { version = "0.2", default-features = false } image = { workspace = true } -craft-image-codecs = { workspace = true } flate2 = { workspace = true } jpeg-encoder = { workspace = true } gif = { workspace = true } # TIFF LZW compression (MIT OR Apache-2.0; already used by the image crate's TIFF reader). weezl = "0.2" +# PNG rows read one at a time (MIT OR Apache-2.0; already used by the image crate's PNG reader). +png = "0.18" log = { workspace = true } # Combining marks inside a lam-alef cluster (MIT OR Apache-2.0; already used by vectorcraft-text). unicode-general-category = "1" @@ -46,7 +47,8 @@ vectorcraft-plugins = { workspace = true } wat = { workspace = true } # Decoders the export tests read their files back with. image = { workspace = true, features = ["tga"] } -craft-tiff = { workspace = true } -craft-psd = { workspace = true } +tiff = "0.11" +# Reads the PSD exports back (MIT OR Apache-2.0). +psd = "0.3" proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index 0a8227b75f..a910480641 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -86,10 +86,8 @@ fn gray_and_bilevel_images_store_gray() { assert_eq!(gray(&file), levels); let (mime, file) = img.to_bilevel(150.0).unwrap(); assert_eq!(mime, "image/tiff"); - let tiff = craft_tiff::Tiff::parse(&file).unwrap(); - let ifd = &tiff.ifds[0]; - assert_eq!(ifd.u16(craft_tiff::tags::SAMPLES_PER_PIXEL), Some(1)); - assert_eq!(ifd.value(craft_tiff::tags::BITS_PER_SAMPLE).unwrap().to_u64_vec(), vec![1]); + let mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap(); + assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1)); let bw: Vec<u8> = [0u8, 0, 255, 255, 255, 0].iter().flat_map(|&g| [g, 255]).collect(); assert_eq!(gray(&file), bw, "from 128 up is white"); // Half-covered white over the light grey; a clear pixel is white. diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index 7655fa16e7..a488cceff2 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -146,7 +146,7 @@ fn unpack(mut src: &[u8], out: &mut Vec<u8>) { /// The composite as a reader sees it. fn merged(f: &[u8]) -> Vec<u8> { - craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes").composite_rgba8().expect("composite").data + ::psd::Psd::from_bytes(f).expect("a PSD a reader takes").rgba() } fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] { @@ -168,12 +168,11 @@ fn layers_are_the_top_level_layers_and_the_merged_image_is_the_render() { assert_eq!(p.records[1].rect, [0, 0, 50, 60], "the blue square and the corner"); // A reader decodes it: the layers, in place, and the merged image. - let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); - assert_eq!((psd.header.width, psd.header.height, psd.layers().len()), (100, 60, 2)); - let layer = psd.iter_layers().find(|l| l.name() == "Layer 1").unwrap(); - assert_eq!(layer.record.rect, craft_psd::layer::Rect { top: 10, left: 10, bottom: 40, right: 40 }); - let red = layer.rgba8().unwrap().data; - assert_eq!(pixel(&red, 30, 10, 10), [255, 0, 0, 255]); + let psd = ::psd::Psd::from_bytes(&file).unwrap(); + assert_eq!((psd.width(), psd.height(), psd.layers().len()), (100, 60, 2)); + let red = psd.layer_by_name("Layer 1").unwrap().rgba(); + assert_eq!(pixel(&red, 100, 20, 20), [255, 0, 0, 255]); + assert_eq!(pixel(&red, 100, 50, 45)[3], 0, "the blue square isn't on Layer 1"); // The merged image is the flat render: colours on white where transparent, alpha apart. let img = Renderer::new().render_region_with(&doc, doc.artboards[0].rect, 1.0, &opts(PsdOptions::default()).render_options(RasterFormat::Psd)); @@ -201,7 +200,7 @@ fn flat_writes_the_merged_image_alone_on_white() { assert_eq!(o.encode(&img, RasterFormat::Psd).unwrap(), file); let file = export(&doc, &RasterExportOptions { ppi: 150.0, ..o }); assert_eq!(u32_at(parse(&file).resources, 12), 150 << 16); - assert_eq!(craft_psd::PsdFile::from_bytes(&file).unwrap().header.width, 208); + assert_eq!(::psd::Psd::from_bytes(&file).unwrap().width(), 208); } #[test] @@ -231,10 +230,8 @@ fn maximum_editability_writes_groups_and_an_object_per_layer() { assert_eq!((p.records[4].opacity, p.records[4].blend, p.records[4].rect), (128, *b"mul ", [20, 30, 50, 60])); assert_eq!((p.records[8].blend, p.records[8].opacity), (*b"pass", 255)); assert_eq!(p.records[6].rect, [0, 0, 6, 6]); - let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); - let content_layers = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::layer::SectionType::Other)).count(); - let groups = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::layer::SectionType::OpenFolder | craft_psd::layer::SectionType::ClosedFolder)).count(); - assert_eq!((content_layers, groups), (3, 3)); + let psd = ::psd::Psd::from_bytes(&file).unwrap(); + assert_eq!((psd.layers().len(), psd.groups().len()), (3, 3)); assert_eq!(merged(&file), merged(&export(&doc, &opts(PsdOptions::default()))), "the same merged image"); // Hidden layers: written hidden, drawn as if visible, instead of left out. @@ -303,8 +300,8 @@ fn a_background_colour_is_the_bottom_layer() { let p = parse(&file); assert_eq!((p.channels, p.count), (3, 3), "opaque: no merged alpha"); assert_eq!((p.records[0].name.as_str(), p.records[0].rect), ("Background", [0, 0, 60, 100])); - let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); - assert!(psd.iter_layers().find(|l| l.name() == "Background").unwrap().rgba8().unwrap().data.chunks(4).all(|p| p == [0, 0, 255, 255])); + let psd = ::psd::Psd::from_bytes(&file).unwrap(); + assert!(psd.layer_by_name("Background").unwrap().rgba().chunks(4).all(|p| p == [0, 0, 255, 255])); assert_eq!(pixel(&merged(&file), 100, 5, 50), [0, 0, 255, 255]); } diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs index 380b6d155c..c70eacd2fc 100644 --- a/crates/render/src/encode/tests_tiff.rs +++ b/crates/render/src/encode/tests_tiff.rs @@ -1,25 +1,22 @@ //! TIFF export: byte order, LZW, colour models and samples, resolution, profile, strips. -use craft_tiff::Tiff; -use craft_tiff::tags; +use std::io::Cursor; + +use tiff::decoder::{Decoder, DecodingResult}; +use tiff::tags::Tag; use super::jpeg::ColorModel; use super::tiff::{self as t, ByteOrder, Compression, Image, Layout, Photometric, TiffOptions}; -fn ifd0(file: &[u8]) -> craft_tiff::reader::Ifd { - Tiff::parse(file).expect("a TIFF").ifds.into_iter().next().expect("IFD0") -} - -fn tag_u16_vec(ifd: &craft_tiff::reader::Ifd, tag: u16) -> Option<Vec<u16>> { - ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u16).collect()) -} - -fn tag_u32_vec(ifd: &craft_tiff::reader::Ifd, tag: u16) -> Option<Vec<u32>> { - ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u32).collect()) +fn decoder(file: &[u8]) -> Decoder<Cursor<&[u8]>> { + Decoder::new(Cursor::new(file)).expect("a TIFF") } fn pixels(file: &[u8]) -> Vec<u8> { - craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, file).expect("decodes").data + match decoder(file).read_image().expect("decodes") { + DecodingResult::U8(v) => v, + _ => panic!("8-bit samples"), + } } /// Straight RGBA: flat areas (as art is) with a transparent corner when `clear`. @@ -49,7 +46,7 @@ fn byte_order_writes_an_ii_or_mm_header() { assert_eq!(&file[..4], magic); let rgb: Vec<u8> = px.chunks(4).flat_map(|p| &p[..3]).copied().collect(); assert_eq!(pixels(&file), rgb, "{order:?}"); - assert_eq!(ifd0(&file).string(tags::SOFTWARE).as_deref(), Some("VectorCraft")); + assert_eq!(decoder(&file).get_tag_ascii_string(Tag::Software).unwrap(), "VectorCraft"); } for (s, o) in [("little", ByteOrder::Little), ("MM", ByteOrder::Big), ("mac", ByteOrder::Big), ("ii", ByteOrder::Little), ("big", ByteOrder::Big)] { @@ -64,51 +61,44 @@ fn lzw_is_smaller_and_decodes_the_same() { let plain = t::encode_samples(&px, 120, 90, 72.0, &TiffOptions { lzw: false, ..opts(ColorModel::Rgb) }).unwrap(); let lzw = t::encode_samples(&px, 120, 90, 72.0, &opts(ColorModel::Rgb)).unwrap(); assert!(lzw.len() * 4 < plain.len(), "{} vs {}", lzw.len(), plain.len()); - assert_eq!(ifd0(&lzw).u16(tags::COMPRESSION), Some(5)); - assert_eq!(ifd0(&plain).u16(tags::COMPRESSION), Some(1)); + assert_eq!(decoder(&lzw).get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 5); + assert_eq!(decoder(&plain).get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 1); assert_eq!(pixels(&lzw), px, "transparency kept as an alpha channel"); assert_eq!(pixels(&plain), px); - let img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &lzw).unwrap(); - assert_eq!(img.data, px); + // The image crate reads it too. + let img = image::load_from_memory_with_format(&lzw, image::ImageFormat::Tiff).unwrap().to_rgba8(); + assert_eq!(img.into_raw(), px); } #[test] fn colour_models_write_their_samples() { let px = art(9, 6, true); let rgba = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); - let rgba_ifd = ifd0(&rgba); - assert_eq!(rgba_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(2)); - assert_eq!(rgba_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(4)); - assert_eq!(tag_u16_vec(&rgba_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8, 8, 8, 8].as_slice())); - assert_eq!(tag_u16_vec(&rgba_ifd, tags::EXTRA_SAMPLES).as_deref(), Some([2].as_slice()), "unassociated alpha"); + assert_eq!(decoder(&rgba).colortype().unwrap(), tiff::ColorType::RGBA(8)); + assert_eq!(decoder(&rgba).get_tag_u16_vec(Tag::ExtraSamples).unwrap(), [2], "unassociated alpha"); let opaque = art(9, 6, false); let rgb = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); - let rgb_ifd = ifd0(&rgb); - assert_eq!(rgb_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(2)); - assert_eq!(rgb_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(3)); - assert!(rgb_ifd.value(tags::EXTRA_SAMPLES).is_none()); + assert_eq!(decoder(&rgb).colortype().unwrap(), tiff::ColorType::RGB(8)); + assert!(decoder(&rgb).find_tag(Tag::ExtraSamples).unwrap().is_none()); let gray = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - let gray_ifd = ifd0(&gray); - assert_eq!(gray_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1)); - assert_eq!(tag_u16_vec(&gray_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8].as_slice())); - assert_eq!(gray_ifd.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(1), "black is zero"); + assert_eq!(decoder(&gray).colortype().unwrap(), tiff::ColorType::Gray(8)); + assert_eq!(decoder(&gray).get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 1, "black is zero"); let g = pixels(&gray); assert_eq!((g[0], g[8]), (super::jpeg::to_gray()([200, 30, 40]), 255), "lightness; white stays white"); let on_white = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - let on_white_ifd = ifd0(&on_white); - assert_eq!(on_white_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1), "grey has no alpha: few readers take it"); + assert_eq!(decoder(&on_white).colortype().unwrap(), tiff::ColorType::Gray(8), "grey has no alpha: few readers take it"); assert_eq!(pixels(&on_white)[0], 255, "the transparent corner is white"); // CMYK: four samples a pixel, ink amounts as given. let inks: Vec<u8> = (0..9 * 6 * 4).map(|i| (i * 7 % 256) as u8).collect(); let cmyk = t::encode_samples(&inks, 9, 6, 72.0, &opts(ColorModel::Cmyk)).unwrap(); - let d = ifd0(&cmyk); - assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(4)); - assert_eq!(d.u16(tags::PHOTOMETRIC_INTERPRETATION), Some(5), "separated"); - assert_eq!(d.u16(332), Some(1), "CMYK inks"); - let cmyk_img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &cmyk).unwrap(); - assert_eq!(cmyk_img.data, inks); + let mut d = decoder(&cmyk); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::CMYK(8)); + assert_eq!(d.get_tag_unsigned::<u16>(Tag::SamplesPerPixel).unwrap(), 4); + assert_eq!(d.get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 5, "separated"); + assert_eq!(d.get_tag_unsigned::<u16>(Tag::Unknown(332)).unwrap(), 1, "CMYK inks"); + assert_eq!(pixels(&cmyk), inks); } #[test] @@ -116,17 +106,16 @@ fn resolution_tags_and_the_profile() { let px = art(4, 4, false); for (ppi, rational) in [(300.0, (300, 1)), (72.5, (7250, 100))] { let file = t::encode_samples(&px, 4, 4, ppi, &opts(ColorModel::Rgb)).unwrap(); - let d = ifd0(&file); - for tag in [tags::X_RESOLUTION, tags::Y_RESOLUTION] { - let Some(craft_tiff::reader::IfdValue::Rationals(v)) = d.value(tag) else { panic!("rational") }; - assert_eq!(v.as_slice(), &[rational]); + let mut d = decoder(&file); + for tag in [Tag::XResolution, Tag::YResolution] { + assert_eq!(d.get_tag(tag).unwrap(), tiff::decoder::ifd::Value::Rational(rational.0, rational.1)); } - assert_eq!(d.u16(tags::RESOLUTION_UNIT), Some(2), "inches"); + assert_eq!(d.get_tag_unsigned::<u16>(Tag::ResolutionUnit).unwrap(), 2, "inches"); } for model in ColorModel::ALL { let src = if model == ColorModel::Cmyk { vec![0; 64] } else { px.clone() }; let file = t::encode_samples(&src, 4, 4, 72.0, &TiffOptions { embed_icc: true, ..opts(model) }).unwrap(); - let icc = ifd0(&file).bytes(tags::ICC_PROFILE).unwrap(); + let icc = decoder(&file).get_tag_u8_vec(Tag::IccProfile).unwrap(); assert_eq!(&icc[36..40], b"acsp", "{model:?}: an ICC profile"); let space: &[u8; 4] = match model { ColorModel::Rgb => b"RGB ", @@ -136,7 +125,7 @@ fn resolution_tags_and_the_profile() { assert_eq!(&icc[16..20], space, "{model:?}"); } let file = t::encode_samples(&px, 4, 4, 72.0, &opts(ColorModel::Rgb)).unwrap(); - assert!(ifd0(&file).value(tags::ICC_PROFILE).is_none()); + assert!(decoder(&file).find_tag(Tag::IccProfile).unwrap().is_none()); } #[test] @@ -146,10 +135,10 @@ fn big_images_take_several_strips_in_either_order() { for order in ByteOrder::ALL { for lzw in [false, true] { let file = t::encode_samples(&px, w, h, 72.0, &TiffOptions { lzw, byte_order: order, ..opts(ColorModel::Gray) }).unwrap(); - let d = ifd0(&file); - let offsets = tag_u32_vec(&d, tags::STRIP_OFFSETS).unwrap(); + let mut d = decoder(&file); + let offsets = d.get_tag_u32_vec(Tag::StripOffsets).unwrap(); // 300 bytes a row: 27 rows a strip. - assert_eq!(d.u32(tags::ROWS_PER_STRIP), Some(27)); + assert_eq!(d.get_tag_u32(Tag::RowsPerStrip).unwrap(), 27); assert_eq!(offsets.len(), 4); assert!(offsets.iter().all(|o| o % 2 == 0), "strips start on a word"); assert_eq!(pixels(&file).len(), (w * h) as usize, "{order:?} lzw {lzw}"); @@ -163,14 +152,11 @@ fn packbits_and_bilevel_images_decode() { let rows = [0b1010_1010u8, 0b1100_0000, 0xFF, 0xC0, 0, 0]; let image = Image { width: 10, height: 3, data: &rows, photometric: Photometric::WhiteIsZero, samples: 1, bits: 1, alpha: false }; let file = t::write(&image, &Layout { byte_order: ByteOrder::Big, compression: Compression::PackBits, ppi: 72.0, icc: None }).unwrap(); - let d = ifd0(&file); - assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(1)); - assert_eq!(tag_u16_vec(&d, tags::BITS_PER_SAMPLE).as_deref(), Some([1].as_slice())); - assert_eq!(d.u16(tags::COMPRESSION), Some(32773)); + let mut d = decoder(&file); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::Gray(1)); + assert_eq!(d.get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 32773); // The decoder turns WhiteIsZero into black-is-zero. - let dec = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &file).unwrap(); - assert_eq!(dec.width, 10); - assert_eq!(dec.height, 3); + assert_eq!(pixels(&file), rows.map(|b| !b)); // A buffer that doesn't match the size is refused, not written. let short = Image { data: &rows[..5], ..image }; assert!(t::write(&short, &Layout { byte_order: ByteOrder::Little, compression: Compression::None, ppi: 72.0, icc: None }).is_err()); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index e51db9b251..c130fbe346 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -258,13 +258,9 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option<Reduction> { return None; } let to = fitted_dims(w, h, allowance)?; - // A PNG that isn't interlaced or animated (and upright in pre-pixel EXIF) is read a row at a time when the reader's buffers fit the allowance. + // A PNG that isn't interlaced is read a row at a time when the reader's buffers fit the allowance. let row_scratch = if format == image::ImageFormat::Png { - craft_image_codecs::PngDecodeRows::new_streaming(bytes, &craft_image_codecs::Limits::default()) - .ok() - .filter(|dec| dec.streamable()) - .and_then(|dec| png_row_scratch(&dec.header())) - .filter(|&s| s <= allowance) + png_reader(bytes).filter(|r| !r.info().interlaced).and_then(|r| png_row_scratch(&r)).filter(|&s| s <= allowance) } else { None }; @@ -287,18 +283,17 @@ const PNG_KEPT_ROWS: u64 = 128 << 10; /// stream buffer grows by. const PNG_WINDOW_AND_STEP: u64 = (32 << 10) + (8 << 10); -/// The most memory `craft-image-codecs`'s PNG row decoder takes in its buffers to read its image -/// a row at a time: the stream buffer holds the rows already read up to [`PNG_KEPT_ROWS`] or four -/// rows, whichever is more, the previous and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer -/// grows by doubling its reservation, and while it grows its old reservation and the new one, up -/// to twice the size it holds, are both allocated: three times that size at most. A copy of a -/// stored row and the row given out come on top. `None` when a row given out doesn't fit memory. -fn png_row_scratch(hdr: &craft_image_codecs::PngHeader) -> Option<u64> { - let out = u64::from(hdr.width) - .checked_mul(u64::from(hdr.layout.channels()))? - .checked_mul(u64::from(hdr.sample.bytes()))?; - // A filter byte and the row's samples. - let stored = 1u64.checked_add(out)?; +/// The most memory `r` takes in its buffers to read its image a row at a time: the stream buffer +/// holds the rows already read up to [`PNG_KEPT_ROWS`] or four rows, whichever is more, the previous +/// and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer grows by doubling its reservation, +/// and while it grows its old reservation and the new one, up to twice the size it holds, are both +/// allocated: three times that size at most. A copy of a stored row and the row given out come on +/// top. `None` when a row given out doesn't fit memory. +fn png_row_scratch(r: &png::Reader<std::io::Cursor<&[u8]>>) -> Option<u64> { + let info = r.info(); + // A filter byte and the row's samples, in whole bytes. + let stored = 1 + (u64::from(info.width) * info.color_type.samples() as u64 * info.bit_depth as u64).div_ceil(8); + let out = u64::try_from(r.output_line_size(info.width)?).ok()?; let kept = (stored * 4).next_multiple_of(64).max(PNG_KEPT_ROWS); let stream = kept + stored * 2 + PNG_WINDOW_AND_STEP; Some(stream * 3 + stored + out) @@ -318,6 +313,14 @@ fn fitted_dims(w: u32, h: u32, allowance: u64) -> Option<(u32, u32)> { (u64::from(nw) * u64::from(nh) * 4 <= allowance).then_some((nw, nh)) } +/// A PNG reader of `bytes` that gives 8-bit gray, gray with alpha, RGB or RGBA rows, with its +/// header and the chunks before the pixels read. +fn png_reader(bytes: &[u8]) -> Option<png::Reader<std::io::Cursor<&[u8]>>> { + let mut d = png::Decoder::new(std::io::Cursor::new(bytes)); + d.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + d.read_info().ok() +} + /// `bytes` decoded at the size [`reduction`] gives. Each pixel of the result is the mean of the /// image's pixels it covers. [`Undecoded::Unreadable`] when they can't be read, and /// [`Undecoded::NoRoom`] when the result and what reading takes ([`Reduction::scratch`]) don't fit @@ -334,58 +337,18 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result<vello_cpu::Pixmap, Unde fn shrunk(bytes: &[u8], r: &Reduction) -> Option<vello_cpu::Pixmap> { let mut shrink = Shrink::new(r.size, r.to); if r.rows { - use craft_image_codecs::{ChannelLayout, CodecError, Format, Limits, PngDecodeRows, SampleType}; - let mut dec = PngDecodeRows::new_streaming(bytes, &Limits::default()).ok()?; - if !dec.streamable() { - return None; - } - let hdr = dec.header(); - dec.step_stream::<CodecError>(1, &mut || true, &mut |_y, row| { - match (hdr.layout, hdr.sample) { - (ChannelLayout::Gray, SampleType::U8) => shrink.row(row.iter().map(|&g| [g, g, g, 255])), - (ChannelLayout::GrayA, SampleType::U8) => { - shrink.row(row.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])) - } - (ChannelLayout::Rgb, SampleType::U8) => { - shrink.row(row.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])) - } - (ChannelLayout::Rgba, SampleType::U8) => shrink.row(row.as_chunks::<4>().0.iter().copied()), - (ChannelLayout::Gray, SampleType::U16) => shrink.row(row.as_chunks::<2>().0.iter().map(|&g| { - let v = (u16::from_ne_bytes(g) >> 8) as u8; - [v, v, v, 255] - })), - (ChannelLayout::GrayA, SampleType::U16) => { - shrink.row(row.as_chunks::<4>().0.iter().map(|&[g0, g1, a0, a1]| { - let g = (u16::from_ne_bytes([g0, g1]) >> 8) as u8; - let a = (u16::from_ne_bytes([a0, a1]) >> 8) as u8; - [g, g, g, a] - })) - } - (ChannelLayout::Rgb, SampleType::U16) => { - shrink.row(row.as_chunks::<6>().0.iter().map(|&[r0, r1, g0, g1, b0, b1]| { - [ - (u16::from_ne_bytes([r0, r1]) >> 8) as u8, - (u16::from_ne_bytes([g0, g1]) >> 8) as u8, - (u16::from_ne_bytes([b0, b1]) >> 8) as u8, - 255, - ] - })) - } - (ChannelLayout::Rgba, SampleType::U16) => { - shrink.row(row.as_chunks::<8>().0.iter().map(|&[r0, r1, g0, g1, b0, b1, a0, a1]| { - [ - (u16::from_ne_bytes([r0, r1]) >> 8) as u8, - (u16::from_ne_bytes([g0, g1]) >> 8) as u8, - (u16::from_ne_bytes([b0, b1]) >> 8) as u8, - (u16::from_ne_bytes([a0, a1]) >> 8) as u8, - ] - })) - } - _ => return Err(CodecError::unsupported(Format::Png, "unsupported PNG channel layout")), + let mut reader = png_reader(bytes)?; + let (color, _) = reader.output_color_type(); + while let Some(row) = reader.next_row().ok()? { + let data = row.data(); + match color { + png::ColorType::Grayscale => shrink.row(data.iter().map(|&g| [g, g, g, 255])), + png::ColorType::GrayscaleAlpha => shrink.row(data.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])), + png::ColorType::Rgb => shrink.row(data.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])), + png::ColorType::Rgba => shrink.row(data.as_chunks::<4>().0.iter().copied()), + png::ColorType::Indexed => return None, } - Ok(()) - }) - .ok()??; + } } else { let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); diff --git a/crates/trace/Cargo.toml b/crates/trace/Cargo.toml index 83f7425c05..a605a69adb 100644 --- a/crates/trace/Cargo.toml +++ b/crates/trace/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } kurbo = { workspace = true } -craft-image-codecs = { workspace = true } +image = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs index 3949adf96a..52e80a368d 100644 --- a/crates/trace/src/opaque.rs +++ b/crates/trace/src/opaque.rs @@ -24,49 +24,26 @@ pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20; /// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`], /// gives [`TraceError::TooLarge`] before it is decoded. pub fn decode_alpha(bytes: &[u8]) -> Result<Option<(u32, u32, Vec<u8>)>, TraceError> { - use craft_image_codecs::{ChannelLayout, DecodeOptions, Limits, RasterColorType, SampleType, decode_with, inspect_raster}; - let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string()); - if let Some(info) = inspect_raster(bytes).map_err(decode_err)? { - if info.width == 0 || info.height == 0 { - return Err(TraceError::Empty); - } - let might_have_alpha = match info.color_type { - RasterColorType::GrayAlpha | RasterColorType::Rgba | RasterColorType::CmykAlpha => true, - RasterColorType::Rgb => info.channels > 3, - RasterColorType::Gray => info.channels > 1, - RasterColorType::Indexed => true, - _ => false, - }; - if !might_have_alpha { - return Ok(None); - } - let pixels = u64::from(info.width) * u64::from(info.height); - let frame = gif_first_frame_pixels(bytes).unwrap_or(0); - let total_bytes = pixels - .saturating_mul(u64::from(info.channels)) - .saturating_mul(u64::from(info.bits_per_sample.max(8).div_ceil(8))); - if pixels.max(frame) > MAX_PIXELS || total_bytes > ALPHA_MAX_DECODE_BYTES { - return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 }); - } - } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { - return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); - } - let opts = DecodeOptions { - limits: Limits { max_pixels: MAX_PIXELS, max_alloc: ALPHA_MAX_DECODE_BYTES, ..Limits::default() }, - ..DecodeOptions::default() - }; - let img = decode_with(bytes, &opts).map_err(decode_err)?; - let (w, h) = (img.width(), img.height()); + use image::ImageDecoder; + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let decoder = reader.into_decoder().map_err(decode_err)?; + let (w, h) = decoder.dimensions(); if w == 0 || h == 0 { return Err(TraceError::Empty); } - if !img.layout().has_alpha() { + if !decoder.color_type().has_alpha() { return Ok(None); } - let alpha: Vec<u8> = match (img.layout(), img.sample_type()) { - (ChannelLayout::Rgba, SampleType::U8) => img.data().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), - (ChannelLayout::GrayA, SampleType::U8) => img.data().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), - _ => img.to_rgba8().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + let pixels = u64::from(w) * u64::from(h); + let frame = gif_first_frame_pixels(bytes).unwrap_or(0); + if pixels.max(frame) > MAX_PIXELS || decoder.total_bytes() > ALPHA_MAX_DECODE_BYTES { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let alpha: Vec<u8> = match image::DynamicImage::from_decoder(decoder).map_err(decode_err)? { + image::DynamicImage::ImageRgba8(img) => img.as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + image::DynamicImage::ImageLumaA8(img) => img.as_raw().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), + img => img.to_rgba8().as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), }; Ok(Some((w, h, alpha))) } @@ -288,41 +265,28 @@ mod tests { } /// `img` encoded as `format`. - fn encoded(img: &craft_image_codecs::Image, format: craft_image_codecs::Format) -> Vec<u8> { - craft_image_codecs::encode(img, format, &Default::default()).unwrap() + fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec<u8> { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out } #[test] fn decode_alpha_reads_the_alpha_of_formats_that_have_one() { - use craft_image_codecs::{ChannelLayout, Format, Image, SampleType}; - let mut rgba_bytes = Vec::with_capacity(4 * 4 * 4); - let mut luma_a_bytes = Vec::with_capacity(4 * 4 * 2); - let mut rgba16_bytes = Vec::with_capacity(4 * 4 * 8); - let mut rgb_bytes = Vec::with_capacity(4 * 4 * 3); - let mut want = Vec::with_capacity(16); - for y in 0..4u32 { - for x in 0..4u32 { - let a = u8::try_from((x + 4 * y) * 16).unwrap(); - want.push(a); - rgba_bytes.extend_from_slice(&[10, 20, 30, a]); - luma_a_bytes.extend_from_slice(&[18, a]); - let a16 = u16::from(a) * 257; - for v in [10u16 * 257, 20 * 257, 30 * 257, a16] { - rgba16_bytes.extend_from_slice(&v.to_ne_bytes()); - } - rgb_bytes.extend_from_slice(&[10, 20, 30]); - } - } - let rgba = Image::from_u8(4, 4, ChannelLayout::Rgba, rgba_bytes).unwrap(); - let luma_a = Image::from_u8(4, 4, ChannelLayout::GrayA, luma_a_bytes).unwrap(); - let rgba16 = Image::new(4, 4, ChannelLayout::Rgba, SampleType::U16, rgba16_bytes).unwrap(); - for (name, img) in [("rgba", rgba), ("gray and alpha", luma_a), ("16 bits", rgba16)] { - let got = decode_alpha(&encoded(&img, Format::Png)).unwrap(); + let px = |x: u32, y: u32| image::Rgba([10, 20, 30, u8::try_from((x + 4 * y) * 16).unwrap()]); + let rgba = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, px)); + let want: Vec<u8> = (0..16).map(|i| u8::try_from(i * 16).unwrap()).collect(); + for (name, img) in [ + ("rgba", rgba.clone()), + ("gray and alpha", image::DynamicImage::ImageLumaA8(rgba.to_luma_alpha8())), + ("16 bits", image::DynamicImage::ImageRgba16(rgba.to_rgba16())), + ] { + let got = decode_alpha(&encoded(img, image::ImageFormat::Png)).unwrap(); assert_eq!(got, Some((4, 4, want.clone())), "{name}"); } - let rgb = Image::from_u8(4, 4, ChannelLayout::Rgb, rgb_bytes).unwrap(); - assert_eq!(decode_alpha(&encoded(&rgb, Format::Png)).unwrap(), None); - assert_eq!(decode_alpha(&encoded(&rgb, Format::Jpeg)).unwrap(), None); + let rgb = image::DynamicImage::ImageRgb8(rgba.to_rgb8()); + assert_eq!(decode_alpha(&encoded(rgb.clone(), image::ImageFormat::Png)).unwrap(), None); + assert_eq!(decode_alpha(&encoded(rgb, image::ImageFormat::Jpeg)).unwrap(), None); assert!(decode_alpha(b"not an image").is_err()); } } diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 0fdce8321d..51312c685a 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -158,100 +158,62 @@ impl Raster { } /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). pub fn decode(bytes: &[u8]) -> Result<Self, TraceError> { - if let Some(info) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? { - if info.width == 0 || info.height == 0 { - return Err(TraceError::Empty); - } - if u64::from(info.width) * u64::from(info.height) > MAX_PIXELS - || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) - { - return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 }); - } - } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { - return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); - } - let opts = craft_image_codecs::DecodeOptions { - limits: craft_image_codecs::Limits { - max_pixels: MAX_PIXELS, - ..craft_image_codecs::Limits::default() - }, - ..craft_image_codecs::DecodeOptions::default() - }; - let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = (img.width(), img.height()); + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; if w == 0 || h == 0 { return Err(TraceError::Empty); } - Ok(Self { width: w, height: h, rgba: img.to_rgba8() }) + if u64::from(w) * u64::from(h) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); + let (w, h) = img.dimensions(); + Ok(Self { width: w, height: h, rgba: img.into_raw() }) } /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly /// less than its own resolution. The caller maps the result back with `width` / `height`. pub fn decode_to_fit(bytes: &[u8]) -> Result<Self, TraceError> { - if let Some(info) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? { - if info.width == 0 || info.height == 0 { - return Err(TraceError::Empty); - } - let pixels = u64::from(info.width) * u64::from(info.height); - if pixels <= MAX_PIXELS && !gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { - return Self::decode(bytes); - } - if pixels > MAX_SOURCE_PIXELS { - return Err(TraceError::TooLarge { - width: info.width, - height: info.height, - max: MAX_SOURCE_PIXELS >> 20, - }); - } - if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { - return Err(TraceError::TooLarge { - width: info.width, - height: info.height, - max: MAX_SOURCE_PIXELS >> 20, - }); - } - } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { - return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_SOURCE_PIXELS >> 20 }); - } - // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. - // A deeper image (16 bits a channel) that needs more fails with a decode error. - let opts = craft_image_codecs::DecodeOptions { - limits: craft_image_codecs::Limits { - max_pixels: MAX_SOURCE_PIXELS, - max_alloc: if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }, - ..craft_image_codecs::Limits::default() - }, - ..craft_image_codecs::DecodeOptions::default() - }; - let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = (img.width(), img.height()); + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; if w == 0 || h == 0 { return Err(TraceError::Empty); } - let rgba = img.to_rgba8(); - if u64::from(w) * u64::from(h) <= MAX_PIXELS { - return Ok(Self { width: w, height: h, rgba }); + let pixels = u64::from(w) * u64::from(h); + if pixels <= MAX_PIXELS { + return Self::decode(bytes); } - Ok(Self::fitted(&Self { width: w, height: h, rgba }, MAX_PIXELS)) + if pixels > MAX_SOURCE_PIXELS { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + } + // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. + // A deeper image (16 bits a channel) that needs more fails with a decode error. + if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); + reader.limits(limits); + Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS)) } /// `img` scaled down, aspect kept, to the largest size of at most `max` pixels. - pub(crate) fn fitted(img: &Self, max: u64) -> Self { - let (nw, nh) = fit_size(img.width, img.height, max); + pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { + let (w, h) = img.dimensions(); + let (nw, nh) = fit_size(w, h, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - Self { width: nw, height: nh, rgba: box_reduced(&img.rgba, img.width, img.height, nw, nh) } + Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec<u8> { - let Ok(img) = craft_image_codecs::Image::from_u8( - self.width, - self.height, - craft_image_codecs::ChannelLayout::Rgba, - self.rgba.clone(), - ) else { - return Vec::new(); - }; - craft_image_codecs::encode(&img, craft_image_codecs::Format::Png, &Default::default()).unwrap_or_default() + let mut out = Vec::new(); + if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { + let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png); + } + out } /// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose /// fields were set to a size its bytes don't have). diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index f2d4760a4d..80eb322bbc 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1094,7 +1094,7 @@ fn scaling_down_weighs_color_by_alpha() { // Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit` // does: each scaled pixel covers a red and a clear one. let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] }); - let decoded = Raster::decode(&half_clear.encode_png()).unwrap(); + let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8(); let fitted = Raster::fitted(&decoded, 2); assert_eq!((fitted.width, fitted.height), (2, 1)); assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat()); @@ -2138,18 +2138,10 @@ fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() { /// `img` saved as a JPEG of `quality` and read back. fn through_jpeg(img: &Raster, quality: u8) -> Raster { let rgb: Vec<u8> = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); - let encoded = craft_image_codecs::Image::from_u8( - img.width, - img.height, - craft_image_codecs::ChannelLayout::Rgb, - rgb, - ) - .unwrap(); - let opts = craft_image_codecs::EncodeOptions { - jpeg_quality: quality, - ..Default::default() - }; - let bytes = craft_image_codecs::encode(&encoded, craft_image_codecs::Format::Jpeg, &opts).unwrap(); + let mut bytes = vec![]; + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality) + .encode(&rgb, img.width, img.height, image::ExtendedColorType::Rgb8) + .unwrap(); Raster::decode(&bytes).unwrap() }